From 0d1a237f8150ed103349ce0485472fb247c3251e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 15 Jan 2025 14:15:58 +0000 Subject: [PATCH 1/9] Lexical: Fixed auto-link issue Added extra test helper to check the editor state directly via string notation access rather than juggling types/objects to access deep properties. --- .../lexical/core/__tests__/utils/index.ts | 29 ++++- .../services/__tests__/auto-links.test.ts | 121 ++++++++---------- resources/js/wysiwyg/services/auto-links.ts | 2 +- resources/js/wysiwyg/todo.md | 6 +- 4 files changed, 82 insertions(+), 76 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index b13bba6977e..d54a64ce89a 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -37,8 +37,6 @@ import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {EditorUiContext} from "../../../../ui/framework/core"; import {EditorUIManager} from "../../../../ui/framework/manager"; -import {turtle} from "@codemirror/legacy-modes/mode/turtle"; - type TestEnv = { readonly container: HTMLDivElement; @@ -47,6 +45,9 @@ type TestEnv = { readonly innerHTML: string; }; +/** + * @deprecated - Consider using `createTestContext` instead within the test case. + */ export function initializeUnitTest( runTests: (testEnv: TestEnv) => void, editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}}, @@ -795,6 +796,30 @@ export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShap expect(shape.children).toMatchObject(expected); } +/** + * Expect a given prop within the JSON editor state structure to be the given value. + * Uses dot notation for the provided `propPath`. Example: + * 0.5.cat => First child, Sixth child, cat property + */ +export function expectEditorStateJSONPropToEqual(editor: LexicalEditor, propPath: string, expected: any) { + let currentItem: any = editor.getEditorState().toJSON().root; + let currentPath = []; + const pathParts = propPath.split('.'); + + for (const part of pathParts) { + currentPath.push(part); + const childAccess = Number.isInteger(Number(part)) && Array.isArray(currentItem.children); + const target = childAccess ? currentItem.children : currentItem; + + if (typeof target[part] === 'undefined') { + throw new Error(`Could not resolve editor state at path ${currentPath.join('.')}`) + } + currentItem = target[part]; + } + + expect(currentItem).toBe(expected); +} + function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); } diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts index 30dc925659e..add61c495a2 100644 --- a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -1,91 +1,76 @@ -import {initializeUnitTest} from "lexical/__tests__/utils"; -import {SerializedLinkNode} from "@lexical/link"; +import { + createTestContext, + dispatchKeydownEventForNode, expectEditorStateJSONPropToEqual, + expectNodeShapeToMatch +} from "lexical/__tests__/utils"; import { $getRoot, ParagraphNode, - SerializedParagraphNode, - SerializedTextNode, TextNode } from "lexical"; import {registerAutoLinks} from "../auto-links"; describe('Auto-link service tests', () => { - initializeUnitTest((testEnv) => { - - test('space after link in text', async () => { - const {editor} = testEnv; - - registerAutoLinks(editor); - let pNode!: ParagraphNode; - - editor.update(() => { - pNode = new ParagraphNode(); - const text = new TextNode('Some https://example.com?test=true text'); - pNode.append(text); - $getRoot().append(pNode); - - text.select(34, 34); - }); + test('space after link in text', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; + + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); + + text.select(34, 34); + }); - editor.commitUpdates(); + dispatchKeydownEventForNode(pNode, editor, ' '); - const pDomEl = editor.getElementByKey(pNode.getKey()); - const event = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: ' ', - keyCode: 62, - }); - pDomEl?.dispatchEvent(event); + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true'); + }); - editor.commitUpdates(); + test('space after link at end of line', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; - const paragraph = editor!.getEditorState().toJSON().root - .children[0] as SerializedParagraphNode; - expect(paragraph.children[1].type).toBe('link'); + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true'); + pNode.append(text); + $getRoot().append(pNode); - const link = paragraph.children[1] as SerializedLinkNode; - expect(link.url).toBe('https://example.com?test=true'); - const linkText = link.children[0] as SerializedTextNode; - expect(linkText.text).toBe('https://example.com?test=true'); + text.selectEnd(); }); - test('enter after link in text', async () => { - const {editor} = testEnv; - - registerAutoLinks(editor); - let pNode!: ParagraphNode; - - editor.update(() => { - pNode = new ParagraphNode(); - const text = new TextNode('Some https://example.com?test=true text'); - pNode.append(text); - $getRoot().append(pNode); + dispatchKeydownEventForNode(pNode, editor, ' '); - text.select(34, 34); - }); + expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [ + {text: 'Some '}, + {type: 'link', children: [{text: 'https://example.com?test=true'}]} + ]}]); + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + }); - editor.commitUpdates(); + test('enter after link in text', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; - const pDomEl = editor.getElementByKey(pNode.getKey()); - const event = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: 'Enter', - keyCode: 66, - }); - pDomEl?.dispatchEvent(event); + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); - editor.commitUpdates(); + text.select(34, 34); + }); - const paragraph = editor!.getEditorState().toJSON().root - .children[0] as SerializedParagraphNode; - expect(paragraph.children[1].type).toBe('link'); + dispatchKeydownEventForNode(pNode, editor, 'Enter'); - const link = paragraph.children[1] as SerializedLinkNode; - expect(link.url).toBe('https://example.com?test=true'); - const linkText = link.children[0] as SerializedTextNode; - expect(linkText.text).toBe('https://example.com?test=true'); - }); + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true'); }); }); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts index 1c3b1c73010..62cd459940c 100644 --- a/resources/js/wysiwyg/services/auto-links.ts +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -43,7 +43,7 @@ function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, edit linkNode.append(new TextNode(textSegment)); const splits = node.splitText(startIndex, cursorPoint); - const targetIndex = splits.length === 3 ? 1 : 0; + const targetIndex = startIndex > 0 ? 1 : 0; const targetText = splits[targetIndex]; if (targetText) { targetText.replace(linkNode); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 817a235a712..a49cccd26dc 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,11 +2,7 @@ ## In progress -Reorg - - Merge custom nodes into original nodes - - Reduce down to use CommonBlockNode where possible - - Remove existing formatType/ElementFormatType references (replaced with alignment). - - Remove existing indent references (replaced with inset). +// ## Main Todo From 7f5fd16dc601039a0ff14749c98d8ea35902ec4c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 15 Jan 2025 14:31:09 +0000 Subject: [PATCH 2/9] Lexical: Added some general test guidance Just to help remember the general layout/methods that we've added to make testing easier. --- resources/js/wysiwyg/testing.md | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 resources/js/wysiwyg/testing.md diff --git a/resources/js/wysiwyg/testing.md b/resources/js/wysiwyg/testing.md new file mode 100644 index 00000000000..7b272c60696 --- /dev/null +++ b/resources/js/wysiwyg/testing.md @@ -0,0 +1,55 @@ +# Testing Guidance + +This is testing guidance specific for this Lexical-based WYSIWYG editor. +There is a lot of pre-existing test code carried over form the fork of lexical, but since there we've added a range of helpers and altered how testing can be done to make things a bit simpler and aligned with how we run tests. + +This document is an attempt to document the new best options for added tests with an aim for standardisation on these approaches going forward. + +## Utils Location + +Most core test utils can be found in the file at path: resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts + +## Test Example + +This is an example of a typical test using the common modern utilities to help perform actions or assertions. Comments are for this example only, and are not expected in actual test files. + +```ts +import { + createTestContext, + dispatchKeydownEventForNode, + expectEditorStateJSONPropToEqual, + expectNodeShapeToMatch +} from "lexical/__tests__/utils"; +import { + $getRoot, + ParagraphNode, + TextNode +} from "lexical"; + +describe('A specific service or file or function', () => { + test('it does thing', async () => { + // Create the editor context and get an editor reference + const {editor} = createTestContext(); + + // Run an action within the editor. + let pNode: ParagraphNode; + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Hello!'); + pNode.append(text); + $getRoot().append(pNode); + }); + + // Dispatch key events via the DOM + dispatchKeydownEventForNode(pNode!, editor, ' '); + + // Check the shape (and text) of the resulting state + expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [ + {text: 'Hello!'}, + ]}]); + + // Check specific props in the resulting JSON state + expectEditorStateJSONPropToEqual(editor, '0.0.text', 'Hello!'); + }); +}); +``` \ No newline at end of file From c091f67db334024bd6b4c65d1833b2c60e3e0a45 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 17 Jan 2025 11:15:14 +0000 Subject: [PATCH 3/9] Lexical: Added color format custom color select Includes tracking of selected colors via localstorage for display. --- resources/icons/editor/color-select.svg | 1 + .../ui/framework/blocks/color-picker.ts | 49 ++++++++++++++++++- resources/js/wysiwyg/ui/framework/core.ts | 7 +++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 resources/icons/editor/color-select.svg diff --git a/resources/icons/editor/color-select.svg b/resources/icons/editor/color-select.svg new file mode 100644 index 00000000000..cef6866558b --- /dev/null +++ b/resources/icons/editor/color-select.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts index b068fb4f0bf..65623e1b21d 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -4,6 +4,8 @@ import {$patchStyleText} from "@lexical/selection"; import {el} from "../../../utils/dom"; import removeIcon from "@icons/editor/color-clear.svg"; +import selectIcon from "@icons/editor/color-select.svg"; +import {uniqueIdSmall} from "../../../../services/util"; const colorChoices = [ '#000000', @@ -34,6 +36,8 @@ const colorChoices = [ '#34495E', ]; +const storageKey = 'bs-lexical-custom-colors'; + export class EditorColorPicker extends EditorUiElement { protected styleProperty: string; @@ -44,8 +48,10 @@ export class EditorColorPicker extends EditorUiElement { } buildDOM(): HTMLElement { + const id = uniqueIdSmall(); - const colorOptions = colorChoices.map(choice => { + const allChoices = [...colorChoices, ...this.getCustomColorChoices()]; + const colorOptions = allChoices.map(choice => { return el('div', { class: 'editor-color-select-option', style: `background-color: ${choice}`, @@ -62,6 +68,25 @@ export class EditorColorPicker extends EditorUiElement { removeButton.innerHTML = removeIcon; colorOptions.push(removeButton); + const selectButton = el('label', { + class: 'editor-color-select-option', + for: `color-select-${id}`, + 'data-color': '', + title: 'Custom color', + }, []); + selectButton.innerHTML = selectIcon; + colorOptions.push(selectButton); + + const input = el('input', {type: 'color', hidden: 'true', id: `color-select-${id}`}) as HTMLInputElement; + colorOptions.push(input); + input.addEventListener('change', e => { + if (input.value) { + this.storeCustomColorChoice(input.value); + this.setColor(input.value); + this.rebuildDOM(); + } + }); + const colorRows = []; for (let i = 0; i < colorOptions.length; i+=5) { const options = colorOptions.slice(i, i + 5); @@ -79,11 +104,33 @@ export class EditorColorPicker extends EditorUiElement { return wrapper; } + storeCustomColorChoice(color: string) { + if (colorChoices.includes(color)) { + return; + } + + const customColors: string[] = this.getCustomColorChoices(); + if (customColors.includes(color)) { + return; + } + + customColors.push(color); + window.localStorage.setItem(storageKey, JSON.stringify(customColors)); + } + + getCustomColorChoices(): string[] { + return JSON.parse(window.localStorage.getItem(storageKey) || '[]'); + } + onClick(event: MouseEvent) { const colorEl = (event.target as HTMLElement).closest('[data-color]') as HTMLElement; if (!colorEl) return; const color = colorEl.dataset.color as string; + this.setColor(color); + } + + setColor(color: string) { this.getContext().editor.update(() => { const selection = $getSelection(); if (selection) { diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 3433b96e8d7..90ce4ebf93c 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -53,6 +53,13 @@ export abstract class EditorUiElement { return this.dom; } + rebuildDOM(): HTMLElement { + const newDOM = this.buildDOM(); + this.dom?.replaceWith(newDOM); + this.dom = newDOM; + return this.dom; + } + trans(text: string) { return this.getContext().translate(text); } From 04cca77ae6d84e0f7c3aceef6c0bc3682258c5c9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 18 Jan 2025 11:12:43 +0000 Subject: [PATCH 4/9] Lexical: Added color picker/indicator to form fields --- resources/icons/editor/color-display.svg | 10 ++++ resources/js/wysiwyg/todo.md | 4 -- .../ui/defaults/buttons/inline-formats.ts | 14 +++++ .../js/wysiwyg/ui/defaults/forms/tables.ts | 21 +++---- resources/js/wysiwyg/ui/defaults/toolbars.ts | 8 +-- .../ui/framework/blocks/color-field.ts | 56 +++++++++++++++++++ .../ui/framework/blocks/color-picker.ts | 19 +++---- .../wysiwyg/ui/framework/blocks/link-field.ts | 2 - resources/js/wysiwyg/ui/framework/forms.ts | 17 ++++-- resources/sass/_editor.scss | 10 ++++ 10 files changed, 125 insertions(+), 36 deletions(-) create mode 100644 resources/icons/editor/color-display.svg create mode 100644 resources/js/wysiwyg/ui/framework/blocks/color-field.ts diff --git a/resources/icons/editor/color-display.svg b/resources/icons/editor/color-display.svg new file mode 100644 index 00000000000..86be9a7bfa7 --- /dev/null +++ b/resources/icons/editor/color-display.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index a49cccd26dc..695e8cb69ed 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -10,13 +10,9 @@ ## Secondary Todo -- Color picker support in table form color fields -- Color picker for color controls - Table caption text support - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Deep check of translation coverage -- About button & view -- Mobile display and handling ## Bugs diff --git a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts index c3726acf001..c5b7ad29ad9 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts @@ -12,6 +12,8 @@ import subscriptIcon from "@icons/editor/subscript.svg"; import codeIcon from "@icons/editor/code.svg"; import formatClearIcon from "@icons/editor/format-clear.svg"; import {$selectionContainsTextFormat} from "../../../utils/selection"; +import {$patchStyleText} from "@lexical/selection"; +import {context} from "esbuild"; function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition { return { @@ -32,6 +34,18 @@ export const underline: EditorButtonDefinition = buildFormatButton('Underline', export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon}; export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon}; +function colorAction(context: EditorUiContext, property: string, color: string): void { + context.editor.update(() => { + const selection = $getSelection(); + if (selection) { + $patchStyleText(selection, {[property]: color || null}); + } + }); +} + +export const textColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color); +export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color); + export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon); diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 63fa24c800f..b592d7c67e6 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -1,6 +1,6 @@ import { EditorFormDefinition, - EditorFormFieldDefinition, + EditorFormFieldDefinition, EditorFormFields, EditorFormTabs, EditorSelectFormFieldDefinition } from "../../framework/forms"; @@ -17,6 +17,7 @@ import { import {formatSizeValue} from "../../../utils/dom"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {CommonBlockAlignment} from "lexical/nodes/common"; +import {colorFieldBuilder} from "../../framework/blocks/color-field"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -145,15 +146,15 @@ export const cellProperties: EditorFormDefinition = { } as EditorSelectFormFieldDefinition, ]; - const advancedFields: EditorFormFieldDefinition[] = [ + const advancedFields: EditorFormFields = [ { label: 'Border width', // inline-style: border-width name: 'border_width', type: 'text', }, borderStyleInput, // inline-style: border-style - borderColorInput, // inline-style: border-color - backgroundColorInput, // inline-style: background-color + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ]; return new EditorFormTabs([ @@ -210,8 +211,8 @@ export const rowProperties: EditorFormDefinition = { type: 'text', }, borderStyleInput, // style on tr: height - borderColorInput, // style on tr: height - backgroundColorInput, // style on tr: height + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ], }; @@ -305,10 +306,10 @@ export const tableProperties: EditorFormDefinition = { alignmentInput, // alignment class ]; - const advancedFields: EditorFormFieldDefinition[] = [ - borderStyleInput, // Style - border-style - borderColorInput, // Style - border-color - backgroundColorInput, // Style - background-color + const advancedFields: EditorFormFields = [ + borderStyleInput, + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ]; return new EditorFormTabs([ diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index 61baa3c3260..b09a7530f1f 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -44,11 +44,11 @@ import { } from "./buttons/block-formats"; import { bold, clearFormating, code, - highlightColor, + highlightColor, highlightColorAction, italic, strikethrough, subscript, superscript, - textColor, + textColor, textColorAction, underline } from "./buttons/inline-formats"; import { @@ -114,10 +114,10 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai new EditorButton(italic), new EditorButton(underline), new EditorDropdownButton({ button: new EditorColorButton(textColor, 'color') }, [ - new EditorColorPicker('color'), + new EditorColorPicker(textColorAction), ]), new EditorDropdownButton({button: new EditorColorButton(highlightColor, 'background-color')}, [ - new EditorColorPicker('background-color'), + new EditorColorPicker(highlightColorAction), ]), new EditorButton(strikethrough), new EditorButton(superscript), diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-field.ts b/resources/js/wysiwyg/ui/framework/blocks/color-field.ts new file mode 100644 index 00000000000..8c8f167d9f7 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/color-field.ts @@ -0,0 +1,56 @@ +import {EditorContainerUiElement, EditorUiBuilderDefinition, EditorUiContext} from "../core"; +import {EditorFormField, EditorFormFieldDefinition} from "../forms"; +import {EditorColorPicker} from "./color-picker"; +import {EditorDropdownButton} from "./dropdown-button"; + +import colorDisplayIcon from "@icons/editor/color-display.svg" + +export class EditorColorField extends EditorContainerUiElement { + protected input: EditorFormField; + protected pickerButton: EditorDropdownButton; + + constructor(input: EditorFormField) { + super([]); + + this.input = input; + + this.pickerButton = new EditorDropdownButton({ + button: { icon: colorDisplayIcon, label: 'Select color'} + }, [ + new EditorColorPicker(this.onColorSelect.bind(this)) + ]); + this.addChildren(this.pickerButton, this.input); + } + + protected buildDOM(): HTMLElement { + const dom = this.input.getDOMElement(); + dom.append(this.pickerButton.getDOMElement()); + dom.classList.add('editor-color-field-container'); + + const field = dom.querySelector('input') as HTMLInputElement; + field.addEventListener('change', () => { + this.setIconColor(field.value); + }); + + return dom; + } + + onColorSelect(color: string, context: EditorUiContext): void { + this.input.setValue(color); + } + + setIconColor(color: string) { + const icon = this.getDOMElement().querySelector('svg .editor-icon-color-display'); + if (icon) { + icon.setAttribute('fill', color || 'url(#pattern2)'); + } + } +} + +export function colorFieldBuilder(field: EditorFormFieldDefinition): EditorUiBuilderDefinition { + return { + build() { + return new EditorColorField(new EditorFormField(field)); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts index 65623e1b21d..c742ddc7723 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -1,6 +1,4 @@ -import {EditorUiElement} from "../core"; -import {$getSelection} from "lexical"; -import {$patchStyleText} from "@lexical/selection"; +import {EditorUiContext, EditorUiElement} from "../core"; import {el} from "../../../utils/dom"; import removeIcon from "@icons/editor/color-clear.svg"; @@ -38,13 +36,15 @@ const colorChoices = [ const storageKey = 'bs-lexical-custom-colors'; +export type EditorColorPickerCallback = (color: string, context: EditorUiContext) => void; + export class EditorColorPicker extends EditorUiElement { - protected styleProperty: string; + protected callback: EditorColorPickerCallback; - constructor(styleProperty: string) { + constructor(callback: EditorColorPickerCallback) { super(); - this.styleProperty = styleProperty; + this.callback = callback; } buildDOM(): HTMLElement { @@ -131,11 +131,6 @@ export class EditorColorPicker extends EditorUiElement { } setColor(color: string) { - this.getContext().editor.update(() => { - const selection = $getSelection(); - if (selection) { - $patchStyleText(selection, {[this.styleProperty]: color || null}); - } - }); + this.callback(color, this.getContext()); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts index f88b22c3f05..880238a9a4c 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts @@ -44,7 +44,6 @@ export class LinkField extends EditorContainerUiElement { updateFormFromHeader(header: HeadingNode) { this.getHeaderIdAndText(header).then(({id, text}) => { - console.log('updating form', id, text); const modal = this.getContext().manager.getActiveModal('link'); if (modal) { modal.getForm().setValues({ @@ -60,7 +59,6 @@ export class LinkField extends EditorContainerUiElement { return new Promise((res) => { this.getContext().editor.update(() => { let id = header.getId(); - console.log('header', id, header.__id); if (!id) { id = 'header-' + uniqueIdSmall(); header.setId(id); diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index 36371e30238..771ab0bdfe5 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -19,15 +19,17 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti valuesByLabel: Record } +export type EditorFormFields = (EditorFormFieldDefinition|EditorUiBuilderDefinition)[]; + interface EditorFormTabDefinition { label: string; - contents: EditorFormFieldDefinition[]; + contents: EditorFormFields; } export interface EditorFormDefinition { submitText: string; action: (formData: FormData, context: EditorUiContext) => Promise; - fields: (EditorFormFieldDefinition|EditorUiBuilderDefinition)[]; + fields: EditorFormFields; } export class EditorFormField extends EditorUiElement { @@ -41,6 +43,7 @@ export class EditorFormField extends EditorUiElement { setValue(value: string) { const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement; input.value = value; + input.dispatchEvent(new Event('change')); } getName(): string { @@ -155,11 +158,17 @@ export class EditorForm extends EditorContainerUiElement { export class EditorFormTab extends EditorContainerUiElement { protected definition: EditorFormTabDefinition; - protected fields: EditorFormField[]; + protected fields: EditorUiElement[]; protected id: string; constructor(definition: EditorFormTabDefinition) { - const fields = definition.contents.map(fieldDef => new EditorFormField(fieldDef)); + const fields = definition.contents.map(fieldDef => { + if (isUiBuilderDefinition(fieldDef)) { + return fieldDef.build(); + } + return new EditorFormField(fieldDef) + }); + super(fields); this.definition = definition; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 2446c141670..9f7694e858c 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -649,6 +649,16 @@ textarea.editor-form-field-input { width: $inputWidth - 40px; } } +.editor-color-field-container { + position: relative; + input { + padding-left: 36px; + } + .editor-dropdown-menu-container { + position: absolute; + bottom: 0; + } +} // Editor theme styles .editor-theme-bold { From 8a66365d48f8c1b4a8926dd632fe0fb1868cdc43 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Jan 2025 12:54:13 +0000 Subject: [PATCH 5/9] Lexical: Added support for table caption nodes Needs linking up to the table form still. --- .../js/wysiwyg/lexical/core/LexicalNode.ts | 10 +++ .../wysiwyg/lexical/core/LexicalReconciler.ts | 21 ++++-- .../lexical/table/LexicalCaptionNode.ts | 74 +++++++++++++++++++ .../wysiwyg/lexical/table/LexicalTableNode.ts | 2 + resources/js/wysiwyg/nodes.ts | 2 + 5 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index a6c9b6023e5..163bb8c31c8 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -1165,6 +1165,16 @@ export class LexicalNode { markDirty(): void { this.getWritable(); } + + /** + * Insert the DOM of this node into that of the parent. + * Allows this node to implement custom DOM attachment logic. + * Boolean result indicates if the insertion was handled by the function. + * A true return value prevents default insertion logic from taking place. + */ + insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean { + return false; + } } function errorOnTypeKlassMismatch( diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts index fccf1ae23a8..297e96ce018 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -171,16 +171,21 @@ function $createNode( } if (parentDOM !== null) { - if (insertDOM != null) { - parentDOM.insertBefore(dom, insertDOM); - } else { - // @ts-expect-error: internal field - const possibleLineBreak = parentDOM.__lexicalLineBreak; - if (possibleLineBreak != null) { - parentDOM.insertBefore(dom, possibleLineBreak); + const inserted = node?.insertDOMIntoParent(dom, parentDOM); + + if (!inserted) { + if (insertDOM != null) { + parentDOM.insertBefore(dom, insertDOM); } else { - parentDOM.appendChild(dom); + // @ts-expect-error: internal field + const possibleLineBreak = parentDOM.__lexicalLineBreak; + + if (possibleLineBreak != null) { + parentDOM.insertBefore(dom, possibleLineBreak); + } else { + parentDOM.appendChild(dom); + } } } } diff --git a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts new file mode 100644 index 00000000000..08c6870e69f --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts @@ -0,0 +1,74 @@ +import { + DOMConversionMap, + DOMExportOutput, + EditorConfig, + ElementNode, + LexicalEditor, + LexicalNode, + SerializedElementNode +} from "lexical"; + + +export class CaptionNode extends ElementNode { + static getType(): string { + return 'caption'; + } + + static clone(node: CaptionNode): CaptionNode { + return new CaptionNode(node.__key); + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement { + return document.createElement('caption'); + } + + updateDOM(_prevNode: unknown, _dom: HTMLElement, _config: EditorConfig): boolean { + return false; + } + + isParentRequired(): true { + return true; + } + + canBeEmpty(): boolean { + return false; + } + + exportJSON(): SerializedElementNode { + return { + ...super.exportJSON(), + type: 'caption', + version: 1, + }; + } + + insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean { + parentDOM.insertBefore(nodeDOM, parentDOM.firstChild); + return true; + } + + static importJSON(serializedNode: SerializedElementNode): CaptionNode { + return $createCaptionNode(); + } + + static importDOM(): DOMConversionMap | null { + return { + caption: (node: Node) => ({ + conversion(domNode: Node) { + return { + node: $createCaptionNode(), + } + }, + priority: 0, + }), + }; + } +} + +export function $createCaptionNode(): CaptionNode { + return new CaptionNode(); +} + +export function $isCaptionNode(node: LexicalNode | null | undefined): node is CaptionNode { + return node instanceof CaptionNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts index 9443747a6f7..a103614753e 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -139,6 +139,8 @@ export class TableNode extends CommonBlockNode { for (const child of Array.from(tableElement.children)) { if (child.nodeName === 'TR') { tBody.append(child); + } else if (child.nodeName === 'CAPTION') { + newElement.insertBefore(child, newElement.firstChild); } else { newElement.append(child); } diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index 8a47f322d6d..c1db0f0869f 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -18,6 +18,7 @@ import {EditorUiContext} from "./ui/framework/core"; import {MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; +import {CaptionNode} from "@lexical/table/LexicalCaptionNode"; /** * Load the nodes for lexical. @@ -32,6 +33,7 @@ export function getNodesForPageEditor(): (KlassConstructor | TableNode, TableRowNode, TableCellNode, + CaptionNode, ImageNode, // TODO - Alignment HorizontalRuleNode, DetailsNode, From 958b537a49c442699ec1834d437ce55c8db6394a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Jan 2025 20:39:15 +0000 Subject: [PATCH 6/9] Lexical: Linked table form to have caption toggle option --- .../lexical/table/LexicalCaptionNode.ts | 18 +++++++++++++++++ resources/js/wysiwyg/todo.md | 1 - .../js/wysiwyg/ui/defaults/forms/tables.ts | 20 +++++++++++++++---- resources/js/wysiwyg/ui/framework/forms.ts | 10 ++++++++-- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts index 08c6870e69f..d9d83562c29 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts @@ -1,4 +1,5 @@ import { + $createTextNode, DOMConversionMap, DOMExportOutput, EditorConfig, @@ -7,6 +8,7 @@ import { LexicalNode, SerializedElementNode } from "lexical"; +import {TableNode} from "@lexical/table/LexicalTableNode"; export class CaptionNode extends ElementNode { @@ -71,4 +73,20 @@ export function $createCaptionNode(): CaptionNode { export function $isCaptionNode(node: LexicalNode | null | undefined): node is CaptionNode { return node instanceof CaptionNode; +} + +export function $tableHasCaption(table: TableNode): boolean { + for (const child of table.getChildren()) { + if ($isCaptionNode(child)) { + return true; + } + } + return false; +} + +export function $addCaptionToTable(table: TableNode, text: string = ''): void { + const caption = $createCaptionNode(); + const textNode = $createTextNode(text || ' '); + caption.append(textNode); + table.append(caption); } \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 695e8cb69ed..1d42ba3e447 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -10,7 +10,6 @@ ## Secondary Todo -- Table caption text support - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Deep check of translation coverage diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index b592d7c67e6..5b484310d9a 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -18,6 +18,7 @@ import {formatSizeValue} from "../../../utils/dom"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {CommonBlockAlignment} from "lexical/nodes/common"; import {colorFieldBuilder} from "../../framework/blocks/color-field"; +import {$addCaptionToTable, $isCaptionNode, $tableHasCaption} from "@lexical/table/LexicalCaptionNode"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -219,6 +220,7 @@ export const rowProperties: EditorFormDefinition = { export function $showTablePropertiesForm(table: TableNode, context: EditorUiContext): EditorFormModal { const styles = table.getStyles(); const modalForm = context.manager.createModal('table_properties'); + modalForm.show({ width: styles.get('width') || '', height: styles.get('height') || '', @@ -228,7 +230,7 @@ export function $showTablePropertiesForm(table: TableNode, context: EditorUiCont border_style: styles.get('border-style') || '', border_color: styles.get('border-color') || '', background_color: styles.get('background-color') || '', - // caption: '', TODO + caption: $tableHasCaption(table) ? 'true' : '', align: table.getAlignment(), }); return modalForm; @@ -265,7 +267,17 @@ export const tableProperties: EditorFormDefinition = { }); } - // TODO - cell caption + const showCaption = Boolean(formData.get('caption')?.toString() || ''); + const hasCaption = $tableHasCaption(table); + if (showCaption && !hasCaption) { + $addCaptionToTable(table, context.translate('Caption')); + } else if (!showCaption && hasCaption) { + for (const child of table.getChildren()) { + if ($isCaptionNode(child)) { + child.remove(); + } + } + } }); return true; }, @@ -299,9 +311,9 @@ export const tableProperties: EditorFormDefinition = { type: 'text', }, { - label: 'caption', // Caption element + label: 'Show caption', // Caption element name: 'caption', - type: 'text', // TODO - + type: 'checkbox', }, alignmentInput, // alignment class ]; diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index 771ab0bdfe5..08edb214e2a 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -11,7 +11,7 @@ import {el} from "../../utils/dom"; export interface EditorFormFieldDefinition { label: string; name: string; - type: 'text' | 'select' | 'textarea'; + type: 'text' | 'select' | 'textarea' | 'checkbox'; } export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition { @@ -42,7 +42,11 @@ export class EditorFormField extends EditorUiElement { setValue(value: string) { const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement; - input.value = value; + if (this.definition.type === 'checkbox') { + input.checked = Boolean(value); + } else { + input.value = value; + } input.dispatchEvent(new Event('change')); } @@ -61,6 +65,8 @@ export class EditorFormField extends EditorUiElement { input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems); } else if (this.definition.type === 'textarea') { input = el('textarea', {id, name: this.definition.name, class: 'editor-form-field-input'}); + } else if (this.definition.type === 'checkbox') { + input = el('input', {id, name: this.definition.name, type: 'checkbox', class: 'editor-form-field-input-checkbox', value: 'true'}); } else { input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'}); } From d89a2fdb150880bf98292bff3e16083179709ffb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Jan 2025 14:28:27 +0000 Subject: [PATCH 7/9] Lexical: Added media src conversions Only actuall added YT in the end. Google had changed URL scheme, and Vimeo seems to just be something else now, can't really browse video pages like before. --- .../lexical/rich-text/LexicalMediaNode.ts | 49 ++++++++++++++++++- resources/js/wysiwyg/todo.md | 1 - .../js/wysiwyg/ui/defaults/buttons/objects.ts | 17 +------ .../js/wysiwyg/ui/defaults/forms/objects.ts | 34 +++++++++++-- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts index a675665ac14..81fb96a936b 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts @@ -16,6 +16,7 @@ import { } from "lexical/nodes/common"; import {$selectSingleNode} from "../../utils/selection"; import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; +import * as url from "node:url"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { @@ -343,11 +344,55 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null { return domElementToNode(tag as MediaNodeTag, el); } +interface UrlPattern { + readonly regex: RegExp; + readonly w: number; + readonly h: number; + readonly url: string; +} + +/** + * These patterns originate from the tinymce/tinymce project. + * https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts + * License: MIT Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc. + * License Link: https://github.com/tinymce/tinymce/blob/584a150679669859a528828e5d2910a083b1d911/LICENSE.TXT + */ +const urlPatterns: UrlPattern[] = [ + { + regex: /.*?youtu\.be\/([\w\-_\?&=.]+)/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$1', + }, + { + regex: /.*youtube\.com(.+)v=([^&]+)(&([a-z0-9&=\-_]+))?.*/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$2?$4', + }, + { + regex: /.*youtube.com\/embed\/([a-z0-9\?&=\-_]+).*/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$1', + }, +]; + const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov']; const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm']; const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx', '']; export function $createMediaNodeFromSrc(src: string): MediaNode { + + for (const pattern of urlPatterns) { + const match = src.match(pattern.regex); + if (match) { + const newSrc = src.replace(pattern.regex, pattern.url); + const node = new MediaNode('iframe'); + node.setSrc(newSrc); + node.setHeight(pattern.h); + node.setWidth(pattern.w); + return node; + } + } + let nodeTag: MediaNodeTag = 'iframe'; const srcEnd = src.split('?')[0].split('/').pop() || ''; const srcEndSplit = srcEnd.split('.'); @@ -360,7 +405,9 @@ export function $createMediaNodeFromSrc(src: string): MediaNode { nodeTag = 'embed'; } - return new MediaNode(nodeTag); + const node = new MediaNode(nodeTag); + node.setSrc(src); + return node; } export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode { diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 1d42ba3e447..94ae0e144ba 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -10,7 +10,6 @@ ## Secondary Todo -- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Deep check of translation coverage ## Bugs diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 6612c0dc451..63df4fea8cd 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -32,7 +32,7 @@ import { } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; -import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects"; +import {$showDetailsForm, $showImageForm, $showLinkForm, $showMediaForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { @@ -168,24 +168,11 @@ export const media: EditorButtonDefinition = { label: 'Insert/edit Media', icon: mediaIcon, action(context: EditorUiContext) { - const mediaModal = context.manager.createModal('media'); - context.editor.getEditorState().read(() => { const selection = $getSelection(); const selectedNode = $getNodeFromSelection(selection, $isMediaNode) as MediaNode | null; - let formDefaults = {}; - if (selectedNode) { - const nodeAttrs = selectedNode.getAttributes(); - formDefaults = { - src: nodeAttrs.src || nodeAttrs.data || '', - width: nodeAttrs.width, - height: nodeAttrs.height, - embed: '', - } - } - - mediaModal.show(formDefaults); + $showMediaForm(selectedNode, context); }); }, isActive(selection: BaseSelection | null): boolean { diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 21d333c3aa2..0effdc1715e 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -186,6 +186,23 @@ export const link: EditorFormDefinition = { ], }; +export function $showMediaForm(media: MediaNode|null, context: EditorUiContext): void { + const mediaModal = context.manager.createModal('media'); + + let formDefaults = {}; + if (media) { + const nodeAttrs = media.getAttributes(); + formDefaults = { + src: nodeAttrs.src || nodeAttrs.data || '', + width: nodeAttrs.width, + height: nodeAttrs.height, + embed: '', + } + } + + mediaModal.show(formDefaults); +} + export const media: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { @@ -215,12 +232,19 @@ export const media: EditorFormDefinition = { const height = (formData.get('height') || '').toString().trim(); const width = (formData.get('width') || '').toString().trim(); - const updateNode = selectedNode || $createMediaNodeFromSrc(src); - updateNode.setSrc(src); - updateNode.setWidthAndHeight(width, height); - if (!selectedNode) { - $insertNodes([updateNode]); + // Update existing + if (selectedNode) { + selectedNode.setSrc(src); + selectedNode.setWidthAndHeight(width, height); + return; + } + + // Insert new + const node = $createMediaNodeFromSrc(src); + if (width || height) { + node.setWidthAndHeight(width, height); } + $insertNodes([node]); }); return true; From 7e03a973d88999f1e22b601d0b2f6c947d0bd5fc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Jan 2025 16:40:41 +0000 Subject: [PATCH 8/9] Lexical: Ran a deeper check on translation use --- lang/en/editor.php | 2 ++ resources/js/wysiwyg/todo.md | 17 ----------------- .../js/wysiwyg/ui/defaults/buttons/controls.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 2 +- .../wysiwyg/ui/framework/blocks/color-picker.ts | 4 ++-- 5 files changed, 6 insertions(+), 21 deletions(-) delete mode 100644 resources/js/wysiwyg/todo.md diff --git a/lang/en/editor.php b/lang/en/editor.php index a61b460427e..752c6f3f702 100644 --- a/lang/en/editor.php +++ b/lang/en/editor.php @@ -13,6 +13,7 @@ 'cancel' => 'Cancel', 'save' => 'Save', 'close' => 'Close', + 'apply' => 'Apply', 'undo' => 'Undo', 'redo' => 'Redo', 'left' => 'Left', @@ -147,6 +148,7 @@ 'url' => 'URL', 'text_to_display' => 'Text to display', 'title' => 'Title', + 'browse_links' => 'Browse links', 'open_link' => 'Open link', 'open_link_in' => 'Open link in...', 'open_link_current' => 'Current window', diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md deleted file mode 100644 index 94ae0e144ba..00000000000 --- a/resources/js/wysiwyg/todo.md +++ /dev/null @@ -1,17 +0,0 @@ -# Lexical based editor todo - -## In progress - -// - -## Main Todo - -// - -## Secondary Todo - -- Deep check of translation coverage - -## Bugs - -// \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts index 5e32005393e..6c22d3faaf8 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -57,7 +57,7 @@ export const redo: EditorButtonDefinition = { export const source: EditorButtonDefinition = { - label: 'Source', + label: 'Source code', icon: sourceIcon, async action(context: EditorUiContext) { const modal = context.manager.createModal('source'); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 63df4fea8cd..4eb4c5a4e68 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -165,7 +165,7 @@ export const diagramManager: EditorButtonDefinition = { }; export const media: EditorButtonDefinition = { - label: 'Insert/edit Media', + label: 'Insert/edit media', icon: mediaIcon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts index c742ddc7723..8e62a0e5e7d 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -63,7 +63,7 @@ export class EditorColorPicker extends EditorUiElement { const removeButton = el('div', { class: 'editor-color-select-option', 'data-color': '', - title: 'Clear color', + title: this.getContext().translate('Remove color'), }, []); removeButton.innerHTML = removeIcon; colorOptions.push(removeButton); @@ -72,7 +72,7 @@ export class EditorColorPicker extends EditorUiElement { class: 'editor-color-select-option', for: `color-select-${id}`, 'data-color': '', - title: 'Custom color', + title: this.getContext().translate('Custom color'), }, []); selectButton.innerHTML = selectIcon; colorOptions.push(selectButton); From 2b746425c9c3d08a89649362396eaa75a1ccf626 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 16 Feb 2025 15:09:33 +0000 Subject: [PATCH 9/9] Lexical: Fixed code in lists, removed extra old alignment code Code in lists could throw error on parse due to inner tag being parsed but not actually used within a
, so this updates the
importDOM to disregard childdren for code blocks.

This also improves the invariant implementation to not be so
dev/debugger based, and to include vars in the output.
---
 .../wysiwyg/lexical/core/LexicalCommands.ts   |  3 ---
 .../wysiwyg/lexical/core/LexicalConstants.ts  | 22 -------------------
 .../js/wysiwyg/lexical/core/LexicalNode.ts    |  6 +++++
 resources/js/wysiwyg/lexical/core/index.ts    |  4 ----
 .../lexical/core/nodes/LexicalElementNode.ts  |  9 --------
 .../lexical/core/nodes/LexicalTextNode.ts     |  5 +++++
 .../wysiwyg/lexical/core/shared/invariant.ts  | 10 ++++-----
 resources/js/wysiwyg/lexical/html/index.ts    |  4 +---
 .../lexical/rich-text/LexicalCodeBlockNode.ts |  9 +++++++-
 .../js/wysiwyg/lexical/rich-text/index.ts     | 21 ------------------
 10 files changed, 25 insertions(+), 68 deletions(-)

diff --git a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts
index 0f1c0a5d31e..f995237a0cf 100644
--- a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts
+++ b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts
@@ -8,7 +8,6 @@
 
 import type {
   BaseSelection,
-  ElementFormatType,
   LexicalCommand,
   LexicalNode,
   TextFormatType,
@@ -91,8 +90,6 @@ export const OUTDENT_CONTENT_COMMAND: LexicalCommand = createCommand(
 );
 export const DROP_COMMAND: LexicalCommand =
   createCommand('DROP_COMMAND');
-export const FORMAT_ELEMENT_COMMAND: LexicalCommand =
-  createCommand('FORMAT_ELEMENT_COMMAND');
 export const DRAGSTART_COMMAND: LexicalCommand =
   createCommand('DRAGSTART_COMMAND');
 export const DRAGOVER_COMMAND: LexicalCommand =
diff --git a/resources/js/wysiwyg/lexical/core/LexicalConstants.ts b/resources/js/wysiwyg/lexical/core/LexicalConstants.ts
index 82461e74d95..55668f1e4bc 100644
--- a/resources/js/wysiwyg/lexical/core/LexicalConstants.ts
+++ b/resources/js/wysiwyg/lexical/core/LexicalConstants.ts
@@ -6,7 +6,6 @@
  *
  */
 
-import type {ElementFormatType} from './nodes/LexicalElementNode';
 import type {
   TextDetailType,
   TextFormatType,
@@ -111,27 +110,6 @@ export const DETAIL_TYPE_TO_DETAIL: Record = {
   unmergeable: IS_UNMERGEABLE,
 };
 
-export const ELEMENT_TYPE_TO_FORMAT: Record<
-  Exclude,
-  number
-> = {
-  center: IS_ALIGN_CENTER,
-  end: IS_ALIGN_END,
-  justify: IS_ALIGN_JUSTIFY,
-  left: IS_ALIGN_LEFT,
-  right: IS_ALIGN_RIGHT,
-  start: IS_ALIGN_START,
-};
-
-export const ELEMENT_FORMAT_TO_TYPE: Record = {
-  [IS_ALIGN_CENTER]: 'center',
-  [IS_ALIGN_END]: 'end',
-  [IS_ALIGN_JUSTIFY]: 'justify',
-  [IS_ALIGN_LEFT]: 'left',
-  [IS_ALIGN_RIGHT]: 'right',
-  [IS_ALIGN_START]: 'start',
-};
-
 export const TEXT_MODE_TO_TYPE: Record = {
   normal: IS_NORMAL,
   segmented: IS_SEGMENTED,
diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts
index 163bb8c31c8..7306e6bca27 100644
--- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts
+++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts
@@ -146,6 +146,12 @@ type NodeName = string;
  * Output for a DOM conversion.
  * Node can be set to 'ignore' to ignore the conversion and handling of the DOMNode
  * including all its children.
+ *
+ * You can specify a function to run for each converted child (forChild) or on all
+ * the child nodes after the conversion is complete (after).
+ * The key difference here is that forChild runs for every deeply nested child node
+ * of the current node, whereas after will run only once after the
+ * transformation of the node and all its children is complete.
  */
 export type DOMConversionOutput = {
   after?: (childLexicalNodes: Array) => Array;
diff --git a/resources/js/wysiwyg/lexical/core/index.ts b/resources/js/wysiwyg/lexical/core/index.ts
index 5ef926b5afc..92cb4a1ca9b 100644
--- a/resources/js/wysiwyg/lexical/core/index.ts
+++ b/resources/js/wysiwyg/lexical/core/index.ts
@@ -49,15 +49,12 @@ export type {
 } from './LexicalNode';
 export type {
   BaseSelection,
-  ElementPointType as ElementPoint,
   NodeSelection,
   Point,
   PointType,
   RangeSelection,
-  TextPointType as TextPoint,
 } from './LexicalSelection';
 export type {
-  ElementFormatType,
   SerializedElementNode,
 } from './nodes/LexicalElementNode';
 export type {SerializedRootNode} from './nodes/LexicalRootNode';
@@ -87,7 +84,6 @@ export {
   DRAGSTART_COMMAND,
   DROP_COMMAND,
   FOCUS_COMMAND,
-  FORMAT_ELEMENT_COMMAND,
   FORMAT_TEXT_COMMAND,
   INDENT_CONTENT_COMMAND,
   INSERT_LINE_BREAK_COMMAND,
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts
index 9624af67e7e..9ad50841141 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts
@@ -46,15 +46,6 @@ export type SerializedElementNode<
   SerializedLexicalNode
 >;
 
-export type ElementFormatType =
-  | 'left'
-  | 'start'
-  | 'center'
-  | 'right'
-  | 'end'
-  | 'justify'
-  | '';
-
 // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
 export interface ElementNode {
   getTopLevelElement(): ElementNode | null;
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts
index 4a3a489504e..7f1b4f30515 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts
@@ -1314,6 +1314,11 @@ const nodeNameToTextFormat: Record = {
 
 function convertTextFormatElement(domNode: HTMLElement): DOMConversionOutput {
   const format = nodeNameToTextFormat[domNode.nodeName.toLowerCase()];
+
+  if (format === 'code' && domNode.closest('pre')) {
+    return {node: null};
+  }
+
   if (format === undefined) {
     return {node: null};
   }
diff --git a/resources/js/wysiwyg/lexical/core/shared/invariant.ts b/resources/js/wysiwyg/lexical/core/shared/invariant.ts
index 0e73848bac1..8e008c11c51 100644
--- a/resources/js/wysiwyg/lexical/core/shared/invariant.ts
+++ b/resources/js/wysiwyg/lexical/core/shared/invariant.ts
@@ -18,9 +18,9 @@ export default function invariant(
     return;
   }
 
-  throw new Error(
-    'Internal Lexical error: invariant() is meant to be replaced at compile ' +
-      'time. There is no runtime version. Error: ' +
-      message,
-  );
+  for (const arg of args) {
+    message = (message || '').replace('%s', arg);
+  }
+
+  throw new Error(message);
 }
diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts
index 5c3cb6cce29..5018e10b4f2 100644
--- a/resources/js/wysiwyg/lexical/html/index.ts
+++ b/resources/js/wysiwyg/lexical/html/index.ts
@@ -11,7 +11,6 @@ import type {
   DOMChildConversion,
   DOMConversion,
   DOMConversionFn,
-  ElementFormatType,
   LexicalEditor,
   LexicalNode,
 } from 'lexical';
@@ -58,6 +57,7 @@ export function $generateNodesFromDOM(
       }
     }
   }
+
   $unwrapArtificalNodes(allArtificialNodes);
 
   return lexicalNodes;
@@ -324,8 +324,6 @@ function wrapContinuousInlines(
   nodes: Array,
   createWrapperFn: () => ElementNode,
 ): Array {
-  const textAlign = (domNode as HTMLElement).style
-    .textAlign as ElementFormatType;
   const out: Array = [];
   let continuousInlines: Array = [];
   // wrap contiguous inline child nodes in para
diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts
index cbe69184887..49ba7754c4c 100644
--- a/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts
+++ b/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts
@@ -145,7 +145,14 @@ export class CodeBlockNode extends DecoratorNode {
                             node.setId(element.id);
                         }
 
-                        return { node };
+                        return {
+                            node,
+                            after(childNodes): LexicalNode[] {
+                                // Remove any child nodes that may get parsed since we're manually
+                                // controlling the code contents.
+                                return [];
+                            },
+                        };
                     },
                     priority: 3,
                 };
diff --git a/resources/js/wysiwyg/lexical/rich-text/index.ts b/resources/js/wysiwyg/lexical/rich-text/index.ts
index c585c028a5a..477fdd78105 100644
--- a/resources/js/wysiwyg/lexical/rich-text/index.ts
+++ b/resources/js/wysiwyg/lexical/rich-text/index.ts
@@ -8,7 +8,6 @@
 
 import type {
   CommandPayloadType,
-  ElementFormatType,
   LexicalCommand,
   LexicalEditor,
   PasteCommandType,
@@ -44,7 +43,6 @@ import {
   DRAGSTART_COMMAND,
   DROP_COMMAND,
   ElementNode,
-  FORMAT_ELEMENT_COMMAND,
   FORMAT_TEXT_COMMAND,
   INSERT_LINE_BREAK_COMMAND,
   INSERT_PARAGRAPH_COMMAND,
@@ -285,25 +283,6 @@ export function registerRichText(editor: LexicalEditor): () => void {
       },
       COMMAND_PRIORITY_EDITOR,
     ),
-    editor.registerCommand(
-      FORMAT_ELEMENT_COMMAND,
-      (format) => {
-        const selection = $getSelection();
-        if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
-          return false;
-        }
-        const nodes = selection.getNodes();
-        for (const node of nodes) {
-          const element = $findMatchingParent(
-            node,
-            (parentNode): parentNode is ElementNode =>
-              $isElementNode(parentNode) && !parentNode.isInline(),
-          );
-        }
-        return true;
-      },
-      COMMAND_PRIORITY_EDITOR,
-    ),
     editor.registerCommand(
       INSERT_LINE_BREAK_COMMAND,
       (selectStart) => {