diff --git a/dev/build/svg-blank-transform.js b/dev/build/svg-blank-transform.js new file mode 100644 index 00000000000..5183014c880 --- /dev/null +++ b/dev/build/svg-blank-transform.js @@ -0,0 +1,14 @@ +// This is a basic transformer stub to help jest handle SVG files. +// Essentially blanks them since we don't really need to involve them +// in our tests (yet). +module.exports = { + process() { + return { + code: 'module.exports = \'\';', + }; + }, + getCacheKey() { + // The output is always the same. + return 'svgTransform'; + }, +}; diff --git a/jest.config.ts b/jest.config.ts index 3c04f05b218..53bfceb053e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -185,6 +185,7 @@ const config: Config = { // A map from regular expressions to paths to transformers transform: { "^.+.tsx?$": ["ts-jest",{}], + "^.+.svg$": ["/dev/build/svg-blank-transform.js",{}], }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation diff --git a/lang/en/editor.php b/lang/en/editor.php index de9aa0ece10..a61b460427e 100644 --- a/lang/en/editor.php +++ b/lang/en/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/resources/icons/editor/help.svg b/resources/icons/editor/about.svg similarity index 100% rename from resources/icons/editor/help.svg rename to resources/icons/editor/about.svg diff --git a/resources/icons/editor/details-toggle.svg b/resources/icons/editor/details-toggle.svg new file mode 100644 index 00000000000..37194e059ca --- /dev/null +++ b/resources/icons/editor/details-toggle.svg @@ -0,0 +1 @@ + diff --git a/resources/js/wysiwyg-tinymce/plugins-about.js b/resources/js/wysiwyg-tinymce/plugins-about.js index 096b4f96805..75cf476cf9f 100644 --- a/resources/js/wysiwyg-tinymce/plugins-about.js +++ b/resources/js/wysiwyg-tinymce/plugins-about.js @@ -4,7 +4,7 @@ function register(editor) { const aboutDialog = { title: 'About the WYSIWYG Editor', - url: window.baseUrl('/help/wysiwyg'), + url: window.baseUrl('/help/tinymce'), }; editor.ui.registry.addButton('about', { diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 9066b402f33..ffdc7d7e82c 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -15,6 +15,7 @@ import {el} from "./utils/dom"; import {registerShortcuts} from "./services/shortcuts"; import {registerNodeResizer} from "./ui/framework/helpers/node-resizer"; import {registerKeyboardHandling} from "./services/keyboard-handling"; +import {registerAutoLinks} from "./services/auto-links"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerTaskListHandler(editor, editArea), registerDropPasteHandling(context), registerNodeResizer(context), + registerAutoLinks(editor), ); listenToCommonEvents(editor); @@ -73,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st const debugView = document.getElementById('lexical-debug'); if (debugView) { debugView.hidden = true; - } - - let changeFromLoading = true; - editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { - // Watch for selection changes to update the UI on change - // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit - // for all selection changes, so this proved more reliable. - const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false); - if (selectionChange) { - editor.update(() => { - const selection = $getSelection(); - context.manager.triggerStateUpdate({ - editor, selection, - }); - }); - } - - // Emit change event to component system (for draft detection) on actual user content change - if (dirtyElements.size > 0 || dirtyLeaves.size > 0) { - if (changeFromLoading) { - changeFromLoading = false; - } else { - window.$events.emit('editor-html-change', ''); - } - } - - // Debug logic - // console.log('editorState', editorState.toJSON()); - if (debugView) { + editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { + // Debug logic + // console.log('editorState', editorState.toJSON()); debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2); - } - }); + }); + } // @ts-ignore window.debugEditorState = () => { diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts index 092429156be..364f6c6b7c3 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts @@ -1188,6 +1188,14 @@ export class LexicalEditor { updateEditor(this, updateFn, options); } + /** + * Helper to run the update and commitUpdates methods in a single call. + */ + updateAndCommit(updateFn: () => void, options?: EditorUpdateOptions): void { + this.update(updateFn, options); + this.commitUpdates(); + } + /** * Focuses the editor * @param callbackFn - A function to run after the editor is focused. diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index c6bc2e642ee..a6c9b6023e5 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -142,10 +142,15 @@ export type DOMConversionMap = Record< >; 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. + */ export type DOMConversionOutput = { after?: (childLexicalNodes: Array) => Array; forChild?: DOMChildConversion; - node: null | LexicalNode | Array; + node: null | LexicalNode | Array | 'ignore'; }; export type DOMExportOutputMap = Map< diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index e9d14ef1139..b13bba6977e 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list'; import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; import { + $getSelection, $isRangeSelection, createEditor, DecoratorNode, @@ -29,14 +30,14 @@ import { TextNode, } from 'lexical'; -import { - CreateEditorArgs, - HTMLConfig, - LexicalNodeReplacement, -} from '../../LexicalEditor'; +import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor'; import {resetRandomKey} from '../../LexicalUtils'; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; 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 = { @@ -420,6 +421,7 @@ const DEFAULT_NODES: NonNullable | LexicalNodeR TableRowNode, AutoLinkNode, LinkNode, + DetailsNode, TestElementNode, TestSegmentedNode, TestExcludeFromCopyElementNode, @@ -451,6 +453,7 @@ export function createTestEditor( ...config, nodes: DEFAULT_NODES.concat(customNodes), }); + return editor; } @@ -465,6 +468,48 @@ export function createTestHeadlessEditor( }); } +export function createTestContext(): EditorUiContext { + + const container = document.createElement('div'); + document.body.appendChild(container); + + const scrollWrap = document.createElement('div'); + const editorDOM = document.createElement('div'); + editorDOM.setAttribute('contenteditable', 'true'); + + scrollWrap.append(editorDOM); + container.append(scrollWrap); + + const editor = createTestEditor({ + namespace: 'testing', + theme: {}, + }); + + editor.setRootElement(editorDOM); + + const context = { + containerDOM: container, + editor: editor, + editorDOM: editorDOM, + error(text: string | Error): void { + }, + manager: new EditorUIManager(), + options: {}, + scrollDOM: scrollWrap, + translate(text: string): string { + return ""; + } + }; + + context.manager.setContext(context); + + return context; +} + +export function destroyFromContext(context: EditorUiContext) { + context.containerDOM.remove(); +} + export function $assertRangeSelection(selection: unknown): RangeSelection { if (!$isRangeSelection(selection)) { throw new Error(`Expected RangeSelection, got ${selection}`); @@ -715,6 +760,61 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void { expect(formatHtml(expected)).toBe(formatHtml(actual)); } +type nodeTextShape = { + text: string; +}; + +type nodeShape = { + type: string; + children?: (nodeShape|nodeTextShape)[]; +} + +export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape { + // @ts-ignore + const children: SerializedLexicalNode[] = (node.children || []); + + const shape: nodeShape = { + type: node.type, + }; + + if (shape.type === 'text') { + // @ts-ignore + return {text: node.text} + } + + if (children.length > 0) { + shape.children = children.map(c => getNodeShape(c)); + } + + return shape; +} + +export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) { + const json = editor.getEditorState().toJSON(); + const shape = getNodeShape(json.root) as nodeShape; + expect(shape.children).toMatchObject(expected); +} + function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); +} + +export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) { + const nodeDomEl = editor.getElementByKey(node.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key, + }); + nodeDomEl?.dispatchEvent(event); + editor.commitUpdates(); +} + +export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) { + editor.getEditorState().read((): void => { + const node = $getSelection()?.getNodes()[0] || null; + if (node) { + dispatchKeydownEventForNode(node, editor, key); + } + }); } \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts index 122516d45b6..c03f1bdb2fb 100644 --- a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts +++ b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts @@ -62,7 +62,6 @@ describe('LexicalHeadlessEditor', () => { it('should be headless environment', async () => { expect(typeof window === 'undefined').toBe(true); expect(typeof document === 'undefined').toBe(true); - expect(typeof navigator === 'undefined').toBe(true); }); it('can update editor', async () => { diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts index 3e962ec72f7..5c3cb6cce29 100644 --- a/resources/js/wysiwyg/lexical/html/index.ts +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -217,6 +217,11 @@ function $createNodesFromDOM( if (transformOutput !== null) { postTransform = transformOutput.after; const transformNodes = transformOutput.node; + + if (transformNodes === 'ignore') { + return lexicalNodes; + } + currentLexicalNode = Array.isArray(transformNodes) ? transformNodes[transformNodes.length - 1] : transformNodes; diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index 33b021298a6..239c49a8c30 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -271,11 +271,18 @@ export class ListItemNode extends ElementNode { insertNewAfter( _: RangeSelection, restoreSelection = true, - ): ListItemNode | ParagraphNode { + ): ListItemNode | ParagraphNode | null { if (this.getTextContent().trim() === '' && this.isLastChild()) { const list = this.getParentOrThrow(); - if (!$isListItemNode(list.getParent())) { + const parentListItem = list.getParent(); + if ($isListItemNode(parentListItem)) { + // Un-nest list item if empty nested item + parentListItem.insertAfter(this); + this.selectStart(); + return null; + } else { + // Insert empty paragraph after list if adding after last empty child const paragraph = $createParagraphNode(); list.insertAfter(paragraph, restoreSelection); this.remove(); diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts index 567714bcd9f..10ff0fc6699 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -9,13 +9,13 @@ import { $createParagraphNode, $createRangeSelection, - $getRoot, + $getRoot, LexicalEditor, TextNode, } from 'lexical'; import { + createTestContext, destroyFromContext, expectHtmlToBeEqual, html, - initializeUnitTest, } from 'lexical/__tests__/utils'; import { @@ -24,49 +24,49 @@ import { ListItemNode, ListNode, } from '../..'; - -const editorConfig = Object.freeze({ - namespace: '', - theme: { - list: { - listitem: 'my-listItem-item-class', - nested: { - listitem: 'my-nested-list-listItem-class', - }, - }, - }, -}); +import {EditorUiContext} from "../../../../ui/framework/core"; +import {$htmlToBlockNodes} from "../../../../utils/nodes"; describe('LexicalListItemNode tests', () => { - initializeUnitTest((testEnv) => { - test('ListItemNode.constructor', async () => { - const {editor} = testEnv; - await editor.update(() => { - const listItemNode = new ListItemNode(); + let context!: EditorUiContext; + let editor!: LexicalEditor; - expect(listItemNode.getType()).toBe('listitem'); + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + }); - expect(listItemNode.getTextContent()).toBe(''); - }); + afterEach(() => { + destroyFromContext(context); + }); + + test('ListItemNode.constructor', async () => { + + await editor.update(() => { + const listItemNode = new ListItemNode(); - expect(() => new ListItemNode()).toThrow(); + expect(listItemNode.getType()).toBe('listitem'); + + expect(listItemNode.getTextContent()).toBe(''); }); - test('ListItemNode.createDOM()', async () => { - const {editor} = testEnv; + expect(() => new ListItemNode()).toThrow(); + }); - await editor.update(() => { - const listItemNode = new ListItemNode(); + test('ListItemNode.createDOM()', async () => { - expectHtmlToBeEqual( - listItemNode.createDOM(editorConfig).outerHTML, + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expectHtmlToBeEqual( + listItemNode.createDOM(editor._config).outerHTML, html`
  • `, - ); + ); - expectHtmlToBeEqual( + expectHtmlToBeEqual( listItemNode.createDOM({ namespace: '', theme: {}, @@ -74,108 +74,105 @@ describe('LexicalListItemNode tests', () => { html`
  • `, - ); - }); + ); }); + }); - describe('ListItemNode.updateDOM()', () => { - test('base', async () => { - const {editor} = testEnv; + describe('ListItemNode.updateDOM()', () => { + test('base', async () => { - await editor.update(() => { - const listItemNode = new ListItemNode(); + await editor.update(() => { + const listItemNode = new ListItemNode(); - const domElement = listItemNode.createDOM(editorConfig); + const domElement = listItemNode.createDOM(editor._config); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
  • `, - ); - const newListItemNode = new ListItemNode(); + ); + const newListItemNode = new ListItemNode(); - const result = newListItemNode.updateDOM( + const result = newListItemNode.updateDOM( listItemNode, domElement, - editorConfig, - ); + editor._config, + ); - expect(result).toBe(false); + expect(result).toBe(false); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
  • `, - ); - }); + ); }); + }); - test('nested list', async () => { - const {editor} = testEnv; + test('nested list', async () => { - await editor.update(() => { - const parentListNode = new ListNode('bullet', 1); - const parentlistItemNode = new ListItemNode(); + await editor.update(() => { + const parentListNode = new ListNode('bullet', 1); + const parentlistItemNode = new ListItemNode(); - parentListNode.append(parentlistItemNode); - const domElement = parentlistItemNode.createDOM(editorConfig); + parentListNode.append(parentlistItemNode); + const domElement = parentlistItemNode.createDOM(editor._config); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
  • `, - ); - const nestedListNode = new ListNode('bullet', 1); - nestedListNode.append(new ListItemNode()); - parentlistItemNode.append(nestedListNode); - const result = parentlistItemNode.updateDOM( + ); + const nestedListNode = new ListNode('bullet', 1); + nestedListNode.append(new ListItemNode()); + parentlistItemNode.append(nestedListNode); + const result = parentlistItemNode.updateDOM( parentlistItemNode, domElement, - editorConfig, - ); + editor._config, + ); - expect(result).toBe(false); + expect(result).toBe(false); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
  • `, - ); - }); + ); }); }); + }); - describe('ListItemNode.replace()', () => { - let listNode: ListNode; - let listItemNode1: ListItemNode; - let listItemNode2: ListItemNode; - let listItemNode3: ListItemNode; + describe('ListItemNode.replace()', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + let listItemNode3: ListItemNode; - beforeEach(async () => { - const {editor} = testEnv; + beforeEach(async () => { - await editor.update(() => { - const root = $getRoot(); - listNode = new ListNode('bullet', 1); - listItemNode1 = new ListItemNode(); + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); - listItemNode1.append(new TextNode('one')); - listItemNode2 = new ListItemNode(); + listItemNode1.append(new TextNode('one')); + listItemNode2 = new ListItemNode(); - listItemNode2.append(new TextNode('two')); - listItemNode3 = new ListItemNode(); + listItemNode2.append(new TextNode('two')); + listItemNode3 = new ListItemNode(); - listItemNode3.append(new TextNode('three')); - root.append(listNode); - listNode.append(listItemNode1, listItemNode2, listItemNode3); - }); + listItemNode3.append(new TextNode('three')); + root.append(listNode); + listNode.append(listItemNode1, listItemNode2, listItemNode3); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); + }); - test('another list item node', async () => { - const {editor} = testEnv; + test('another list item node', async () => { - await editor.update(() => { - const newListItemNode = new ListItemNode(); + await editor.update(() => { + const newListItemNode = new ListItemNode(); - newListItemNode.append(new TextNode('bar')); - listItemNode1.replace(newListItemNode); - }); + newListItemNode.append(new TextNode('bar')); + listItemNode1.replace(newListItemNode); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); + }); - test('first list item with a non list item node', async () => { - const {editor} = testEnv; + test('first list item with a non list item node', async () => { - await editor.update(() => { - return; - }); + await editor.update(() => { + return; + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode1.replace(paragraphNode); - }); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); + }); - test('last list item with a non list item node', async () => { - const {editor} = testEnv; + test('last list item with a non list item node', async () => { - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode3.replace(paragraphNode); - }); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode3.replace(paragraphNode); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {


    `, - ); - }); + ); + }); - test('middle list item with a non list item node', async () => { - const {editor} = testEnv; + test('middle list item with a non list item node', async () => { - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode2.replace(paragraphNode); - }); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode2.replace(paragraphNode); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); + }); - test('the only list item with a non list item node', async () => { - const {editor} = testEnv; + test('the only list item with a non list item node', async () => { - await editor.update(() => { - listItemNode2.remove(); - listItemNode3.remove(); - }); + await editor.update(() => { + listItemNode2.remove(); + listItemNode3.remove(); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode1.replace(paragraphNode); - }); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {


    `, - ); - }); + ); }); + }); - describe('ListItemNode.remove()', () => { - // - A - // - x - // - B - test('siblings are not nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; + describe('ListItemNode.remove()', () => { + // - A + // - x + // - B + test('siblings are not nested', async () => { + let x: ListItemNode; - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); - const A_listItem = new ListItemNode(); - A_listItem.append(new TextNode('A')); + const A_listItem = new ListItemNode(); + A_listItem.append(new TextNode('A')); - x = new ListItemNode(); - x.append(new TextNode('x')); + x = new ListItemNode(); + x.append(new TextNode('x')); - const B_listItem = new ListItemNode(); - B_listItem.append(new TextNode('B')); + const B_listItem = new ListItemNode(); + B_listItem.append(new TextNode('B')); - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); + }); - // - A - // - x - // - B - test('the previous sibling is nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; + // - A + // - x + // - B + test('the previous sibling is nested', async () => { + let x: ListItemNode; - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem); - A_nestedListItem.append(new TextNode('A')); + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); - x = new ListItemNode(); - x.append(new TextNode('x')); + x = new ListItemNode(); + x.append(new TextNode('x')); - const B_listItem = new ListItemNode(); - B_listItem.append(new TextNode('B')); + const B_listItem = new ListItemNode(); + B_listItem.append(new TextNode('B')); - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -504,12 +494,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -524,39 +514,38 @@ describe('LexicalListItemNode tests', () => {
    `, - ); - }); + ); + }); - // - A - // - x - // - B - test('the next sibling is nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; + // - A + // - x + // - B + test('the next sibling is nested', async () => { + let x: ListItemNode; - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); - const A_listItem = new ListItemNode(); - A_listItem.append(new TextNode('A')); + const A_listItem = new ListItemNode(); + A_listItem.append(new TextNode('A')); - x = new ListItemNode(); - x.append(new TextNode('x')); + x = new ListItemNode(); + x.append(new TextNode('x')); - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem); - B_nestedListItem.append(new TextNode('B')); + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem); + B_nestedListItem.append(new TextNode('B')); - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -574,12 +563,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -594,43 +583,42 @@ describe('LexicalListItemNode tests', () => {
    `, - ); - }); + ); + }); - // - A - // - x - // - B - test('both siblings are nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem); - A_nestedListItem.append(new TextNode('A')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem); - B_nestedListItem.append(new TextNode('B')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + // - A + // - x + // - B + test('both siblings are nested', async () => { + let x: ListItemNode; - expectHtmlToBeEqual( - testEnv.innerHTML, + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem); + B_nestedListItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -652,12 +640,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -672,51 +660,50 @@ describe('LexicalListItemNode tests', () => {
    `, - ); - }); + ); + }); - // - A1 - // - A2 - // - x - // - B - test('the previous sibling is nested deeper than the next sibling', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem1 = new ListItemNode(); - const A_nestedListItem2 = new ListItemNode(); - const A_deeplyNestedList = new ListNode('bullet', 1); - const A_deeplyNestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem1); - A_nestedList.append(A_nestedListItem2); - A_nestedListItem1.append(new TextNode('A1')); - A_nestedListItem2.append(A_deeplyNestedList); - A_deeplyNestedList.append(A_deeplyNestedListItem); - A_deeplyNestedListItem.append(new TextNode('A2')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedlistItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedlistItem); - B_nestedlistItem.append(new TextNode('B')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + // - A1 + // - A2 + // - x + // - B + test('the previous sibling is nested deeper than the next sibling', async () => { + let x: ListItemNode; - expectHtmlToBeEqual( - testEnv.innerHTML, + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem1 = new ListItemNode(); + const A_nestedListItem2 = new ListItemNode(); + const A_deeplyNestedList = new ListNode('bullet', 1); + const A_deeplyNestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem1); + A_nestedList.append(A_nestedListItem2); + A_nestedListItem1.append(new TextNode('A1')); + A_nestedListItem2.append(A_deeplyNestedList); + A_deeplyNestedList.append(A_deeplyNestedListItem); + A_deeplyNestedListItem.append(new TextNode('A2')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedlistItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedlistItem); + B_nestedlistItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -745,12 +732,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -772,51 +759,50 @@ describe('LexicalListItemNode tests', () => {
    `, - ); - }); + ); + }); - // - A - // - x - // - B1 - // - B2 - test('the next sibling is nested deeper than the previous sibling', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem); - A_nestedListItem.append(new TextNode('A')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem1 = new ListItemNode(); - const B_nestedListItem2 = new ListItemNode(); - const B_deeplyNestedList = new ListNode('bullet', 1); - const B_deeplyNestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem1); - B_nestedList.append(B_nestedListItem2); - B_nestedListItem1.append(B_deeplyNestedList); - B_nestedListItem2.append(new TextNode('B2')); - B_deeplyNestedList.append(B_deeplyNestedListItem); - B_deeplyNestedListItem.append(new TextNode('B1')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + // - A + // - x + // - B1 + // - B2 + test('the next sibling is nested deeper than the previous sibling', async () => { + let x: ListItemNode; - expectHtmlToBeEqual( - testEnv.innerHTML, + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem1 = new ListItemNode(); + const B_nestedListItem2 = new ListItemNode(); + const B_deeplyNestedList = new ListNode('bullet', 1); + const B_deeplyNestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem1); + B_nestedList.append(B_nestedListItem2); + B_nestedListItem1.append(B_deeplyNestedList); + B_nestedListItem2.append(new TextNode('B2')); + B_deeplyNestedList.append(B_deeplyNestedListItem); + B_deeplyNestedListItem.append(new TextNode('B1')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -845,12 +831,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -872,59 +858,58 @@ describe('LexicalListItemNode tests', () => {
    `, - ); - }); + ); + }); - // - A1 - // - A2 - // - x - // - B1 - // - B2 - test('both siblings are deeply nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem1 = new ListItemNode(); - const A_nestedListItem2 = new ListItemNode(); - const A_deeplyNestedList = new ListNode('bullet', 1); - const A_deeplyNestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem1); - A_nestedList.append(A_nestedListItem2); - A_nestedListItem1.append(new TextNode('A1')); - A_nestedListItem2.append(A_deeplyNestedList); - A_deeplyNestedList.append(A_deeplyNestedListItem); - A_deeplyNestedListItem.append(new TextNode('A2')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem1 = new ListItemNode(); - const B_nestedListItem2 = new ListItemNode(); - const B_deeplyNestedList = new ListNode('bullet', 1); - const B_deeplyNestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem1); - B_nestedList.append(B_nestedListItem2); - B_nestedListItem1.append(B_deeplyNestedList); - B_nestedListItem2.append(new TextNode('B2')); - B_deeplyNestedList.append(B_deeplyNestedListItem); - B_deeplyNestedListItem.append(new TextNode('B1')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + // - A1 + // - A2 + // - x + // - B1 + // - B2 + test('both siblings are deeply nested', async () => { + let x: ListItemNode; - expectHtmlToBeEqual( - testEnv.innerHTML, + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem1 = new ListItemNode(); + const A_nestedListItem2 = new ListItemNode(); + const A_deeplyNestedList = new ListNode('bullet', 1); + const A_deeplyNestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem1); + A_nestedList.append(A_nestedListItem2); + A_nestedListItem1.append(new TextNode('A1')); + A_nestedListItem2.append(A_deeplyNestedList); + A_deeplyNestedList.append(A_deeplyNestedListItem); + A_deeplyNestedListItem.append(new TextNode('A2')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem1 = new ListItemNode(); + const B_nestedListItem2 = new ListItemNode(); + const B_deeplyNestedList = new ListNode('bullet', 1); + const B_deeplyNestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem1); + B_nestedList.append(B_nestedListItem2); + B_nestedListItem1.append(B_deeplyNestedList); + B_nestedListItem2.append(new TextNode('B2')); + B_deeplyNestedList.append(B_deeplyNestedListItem); + B_deeplyNestedListItem.append(new TextNode('B1')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -960,12 +945,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -990,37 +975,36 @@ describe('LexicalListItemNode tests', () => {
    `, - ); - }); + ); }); + }); - describe('ListItemNode.insertNewAfter(): non-empty list items', () => { - let listNode: ListNode; - let listItemNode1: ListItemNode; - let listItemNode2: ListItemNode; - let listItemNode3: ListItemNode; + describe('ListItemNode.insertNewAfter(): non-empty list items', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + let listItemNode3: ListItemNode; - beforeEach(async () => { - const {editor} = testEnv; + beforeEach(async () => { - await editor.update(() => { - const root = $getRoot(); - listNode = new ListNode('bullet', 1); - listItemNode1 = new ListItemNode(); + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); - listItemNode2 = new ListItemNode(); + listItemNode2 = new ListItemNode(); - listItemNode3 = new ListItemNode(); + listItemNode3 = new ListItemNode(); - root.append(listNode); - listNode.append(listItemNode1, listItemNode2, listItemNode3); - listItemNode1.append(new TextNode('one')); - listItemNode2.append(new TextNode('two')); - listItemNode3.append(new TextNode('three')); - }); + root.append(listNode); + listNode.append(listItemNode1, listItemNode2, listItemNode3); + listItemNode1.append(new TextNode('one')); + listItemNode2.append(new TextNode('two')); + listItemNode3.append(new TextNode('three')); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); + }); - test('first list item', async () => { - const {editor} = testEnv; + test('first list item', async () => { - await editor.update(() => { - listItemNode1.insertNewAfter($createRangeSelection()); - }); + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); + }); - test('last list item', async () => { - const {editor} = testEnv; + test('last list item', async () => { - await editor.update(() => { - listItemNode3.insertNewAfter($createRangeSelection()); - }); + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); + }); - test('middle list item', async () => { - const {editor} = testEnv; + test('middle list item', async () => { - await editor.update(() => { - listItemNode3.insertNewAfter($createRangeSelection()); - }); + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); - - test('the only list item', async () => { - const {editor} = testEnv; + ); + }); - await editor.update(() => { - listItemNode2.remove(); - listItemNode3.remove(); - }); + test('the only list item', async () => { + await editor.update(() => { + listItemNode2.remove(); + listItemNode3.remove(); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); - await editor.update(() => { - listItemNode1.insertNewAfter($createRangeSelection()); - }); + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); }); + }); - test('$createListItemNode()', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const listItemNode = new ListItemNode(); - - const createdListItemNode = $createListItemNode(); + describe('ListItemNode.insertNewAfter()', () => { + test('new items after empty nested items un-nests the current item instead of creating new', () => { + let nestedItem!: ListItemNode; + const input = `
      +
    • + Item A +
      • Nested item A
      +
    • +
    • Item B
    • +
    `; + + editor.updateAndCommit(() => { + const root = $getRoot(); + root.append(...$htmlToBlockNodes(editor, input)); + const list = root.getFirstChild() as ListNode; + const itemA = list.getFirstChild() as ListItemNode; + const nestedList = itemA.getLastChild() as ListNode; + nestedItem = nestedList.getFirstChild() as ListItemNode; + nestedList.selectEnd(); + }); - expect(listItemNode.__type).toEqual(createdListItemNode.__type); - expect(listItemNode.__parent).toEqual(createdListItemNode.__parent); - expect(listItemNode.__key).not.toEqual(createdListItemNode.__key); + editor.updateAndCommit(() => { + nestedItem.insertNewAfter($createRangeSelection()); + const newItem = nestedItem.getNextSibling() as ListItemNode; + newItem.insertNewAfter($createRangeSelection()); }); + + expectHtmlToBeEqual( + context.editorDOM.innerHTML, + html`
      +
    • + Item A +
      • Nested item A
      +
    • +

    • +
    • Item B
    • +
    `, + ); }); + }); - test('$isListItemNode()', async () => { - const {editor} = testEnv; + test('$createListItemNode()', async () => { + await editor.update(() => { + const listItemNode = new ListItemNode(); - await editor.update(() => { - const listItemNode = new ListItemNode(); + const createdListItemNode = $createListItemNode(); - expect($isListItemNode(listItemNode)).toBe(true); - }); + expect(listItemNode.__type).toEqual(createdListItemNode.__type); + expect(listItemNode.__parent).toEqual(createdListItemNode.__parent); + expect(listItemNode.__key).not.toEqual(createdListItemNode.__key); + }); + }); + + test('$isListItemNode()', async () => { + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expect($isListItemNode(listItemNode)).toBe(true); }); }); }); diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index 178b0d9531d..3c845359aec 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -5,18 +5,20 @@ import { LexicalEditor, LexicalNode, SerializedElementNode, Spread, - EditorConfig, + EditorConfig, DOMExportOutput, } from 'lexical'; -import {el} from "../../utils/dom"; import {extractDirectionFromElement} from "lexical/nodes/common"; export type SerializedDetailsNode = Spread<{ id: string; + summary: string; }, SerializedElementNode> export class DetailsNode extends ElementNode { __id: string = ''; + __summary: string = ''; + __open: boolean = false; static getType() { return 'details'; @@ -32,10 +34,32 @@ export class DetailsNode extends ElementNode { return self.__id; } + setSummary(summary: string) { + const self = this.getWritable(); + self.__summary = summary; + } + + getSummary(): string { + const self = this.getLatest(); + return self.__summary; + } + + setOpen(open: boolean) { + const self = this.getWritable(); + self.__open = open; + } + + getOpen(): boolean { + const self = this.getLatest(); + return self.__open; + } + static clone(node: DetailsNode): DetailsNode { const newNode = new DetailsNode(node.__key); newNode.__id = node.__id; newNode.__dir = node.__dir; + newNode.__summary = node.__summary; + newNode.__open = node.__open; return newNode; } @@ -49,12 +73,34 @@ export class DetailsNode extends ElementNode { el.setAttribute('dir', this.__dir); } + if (this.__open) { + el.setAttribute('open', 'true'); + } + + const summary = document.createElement('summary'); + summary.textContent = this.__summary; + summary.setAttribute('contenteditable', 'false'); + summary.addEventListener('click', event => { + event.preventDefault(); + _editor.update(() => { + this.select(); + }) + }); + + el.append(summary); + return el; } updateDOM(prevNode: DetailsNode, dom: HTMLElement) { + + if (prevNode.__open !== this.__open) { + dom.toggleAttribute('open', this.__open); + } + return prevNode.__id !== this.__id - || prevNode.__dir !== this.__dir; + || prevNode.__dir !== this.__dir + || prevNode.__summary !== this.__summary; } static importDOM(): DOMConversionMap|null { @@ -71,20 +117,44 @@ export class DetailsNode extends ElementNode { node.setDirection(extractDirectionFromElement(element)); } + const summaryElem = Array.from(element.children).find(e => e.nodeName === 'SUMMARY'); + node.setSummary(summaryElem?.textContent || ''); + return {node}; }, priority: 3, }; }, + summary(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + return {node: 'ignore'}; + }, + priority: 3, + }; + }, }; } + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config, editor); + const editable = element.querySelectorAll('[contenteditable]'); + for (const elem of editable) { + elem.removeAttribute('contenteditable'); + } + + element.removeAttribute('open'); + + return {element}; + } + exportJSON(): SerializedDetailsNode { return { ...super.exportJSON(), type: 'details', version: 1, id: this.__id, + summary: this.__summary, }; } @@ -104,58 +174,3 @@ export function $createDetailsNode() { export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode { return node instanceof DetailsNode; } - -export class SummaryNode extends ElementNode { - - static getType() { - return 'summary'; - } - - static clone(node: SummaryNode) { - return new SummaryNode(node.__key); - } - - createDOM(_config: EditorConfig, _editor: LexicalEditor) { - return el('summary'); - } - - updateDOM(prevNode: DetailsNode, dom: HTMLElement) { - return false; - } - - static importDOM(): DOMConversionMap|null { - return { - summary(node: HTMLElement): DOMConversion|null { - return { - conversion: (element: HTMLElement): DOMConversionOutput|null => { - return { - node: new SummaryNode(), - }; - }, - priority: 3, - }; - }, - }; - } - - exportJSON(): SerializedElementNode { - return { - ...super.exportJSON(), - type: 'summary', - version: 1, - }; - } - - static importJSON(serializedNode: SerializedElementNode): SummaryNode { - return $createSummaryNode(); - } - -} - -export function $createSummaryNode(): SummaryNode { - return new SummaryNode(); -} - -export function $isSummaryNode(node: LexicalNode | null | undefined): node is SummaryNode { - return node instanceof SummaryNode; -} diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts new file mode 100644 index 00000000000..faa31d8874d --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts @@ -0,0 +1,40 @@ +import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils"; +import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical"; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + }, +}); + +describe('LexicalDetailsNode tests', () => { + initializeUnitTest((testEnv) => { + + test('createDOM()', () => { + const {editor} = testEnv; + let html!: string; + + editor.updateAndCommit(() => { + const details = $createDetailsNode(); + html = details.createDOM(editorConfig, editor).outerHTML; + }); + + expect(html).toBe(`
    `); + }); + + test('exportDOM()', () => { + const {editor} = testEnv; + let html!: string; + + editor.updateAndCommit(() => { + const details = $createDetailsNode(); + html = (details.exportDOM(editor).element as HTMLElement).outerHTML; + }); + + expect(html).toBe(`
    `); + }); + + + }); +}) \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index eb836bdce02..8a47f322d6d 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -8,7 +8,7 @@ import { } from "lexical"; import {LinkNode} from "@lexical/link"; import {ImageNode} from "@lexical/rich-text/LexicalImageNode"; -import {DetailsNode, SummaryNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {ListItemNode, ListNode} from "@lexical/list"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {HorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode"; @@ -34,7 +34,7 @@ export function getNodesForPageEditor(): (KlassConstructor | TableCellNode, ImageNode, // TODO - Alignment HorizontalRuleNode, - DetailsNode, SummaryNode, + DetailsNode, CodeBlockNode, DiagramNode, MediaNode, // TODO - Alignment diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts new file mode 100644 index 00000000000..30dc925659e --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -0,0 +1,91 @@ +import {initializeUnitTest} from "lexical/__tests__/utils"; +import {SerializedLinkNode} from "@lexical/link"; +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); + }); + + editor.commitUpdates(); + + const pDomEl = editor.getElementByKey(pNode.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: ' ', + keyCode: 62, + }); + pDomEl?.dispatchEvent(event); + + editor.commitUpdates(); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + expect(paragraph.children[1].type).toBe('link'); + + 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'); + }); + + 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); + + text.select(34, 34); + }); + + editor.commitUpdates(); + + const pDomEl = editor.getElementByKey(pNode.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Enter', + keyCode: 66, + }); + pDomEl?.dispatchEvent(event); + + editor.commitUpdates(); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + expect(paragraph.children[1].type).toBe('link'); + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts new file mode 100644 index 00000000000..736c3573c5b --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -0,0 +1,130 @@ +import { + createTestContext, destroyFromContext, + dispatchKeydownEventForNode, + dispatchKeydownEventForSelectedNode, +} from "lexical/__tests__/utils"; +import { + $createParagraphNode, $createTextNode, + $getRoot, $getSelection, LexicalEditor, LexicalNode, + ParagraphNode, TextNode, +} from "lexical"; +import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {registerKeyboardHandling} from "../keyboard-handling"; +import {registerRichText} from "@lexical/rich-text"; +import {EditorUiContext} from "../../ui/framework/core"; +import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list"; + +describe('Keyboard-handling service tests', () => { + + let context!: EditorUiContext; + let editor!: LexicalEditor; + + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + registerRichText(editor); + registerKeyboardHandling(context); + }); + + afterEach(() => { + destroyFromContext(context); + }); + + test('Details: down key on last lines creates new sibling node', () => { + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; + + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + detailsPara = $createParagraphNode(); + details.append(detailsPara); + $getRoot().append(details); + detailsPara.select(); + + lastRootChild = root.getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(DetailsNode); + + dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); + + editor.getEditorState().read(() => { + lastRootChild = $getRoot().getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + }); + + test('Details: enter on last empty block creates new sibling node', () => { + registerRichText(editor); + + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; + + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + const text = $createTextNode('Hello!'); + detailsPara = $createParagraphNode(); + detailsPara.append(text); + details.append(detailsPara); + $getRoot().append(details); + text.selectEnd(); + + lastRootChild = root.getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(DetailsNode); + + dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); + dispatchKeydownEventForSelectedNode(editor, 'Enter'); + + let detailsChildren!: LexicalNode[]; + let lastDetailsText!: string; + + editor.getEditorState().read(() => { + detailsChildren = (lastRootChild as DetailsNode).getChildren(); + lastRootChild = $getRoot().getLastChild(); + lastDetailsText = detailsChildren[0].getTextContent(); + }); + + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + expect(detailsChildren).toHaveLength(1); + expect(lastDetailsText).toBe('Hello!'); + }); + + test('Lists: tab on empty list item insets item', () => { + + let list!: ListNode; + let listItemB!: ListItemNode; + + editor.updateAndCommit(() => { + const root = $getRoot(); + list = $createListNode('bullet'); + const listItemA = $createListItemNode(); + listItemA.append($createTextNode('Hello!')); + listItemB = $createListItemNode(); + list.append(listItemA, listItemB); + root.append(list); + listItemB.selectStart(); + }); + + dispatchKeydownEventForNode(listItemB, editor, 'Tab'); + + editor.getEditorState().read(() => { + const list = $getRoot().getChildren()[0] as ListNode; + const listChild = list.getChildren()[0] as ListItemNode; + const children = listChild.getChildren(); + expect(children).toHaveLength(2); + expect(children[0]).toBeInstanceOf(TextNode); + expect(children[0].getTextContent()).toBe('Hello!'); + expect(children[1]).toBeInstanceOf(ListNode); + + const innerList = children[1] as ListNode; + const selectedNode = $getSelection()?.getNodes()[0]; + expect(selectedNode).toBeInstanceOf(ListItemNode); + expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey()); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts new file mode 100644 index 00000000000..1c3b1c73010 --- /dev/null +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -0,0 +1,74 @@ +import { + $getSelection, BaseSelection, + COMMAND_PRIORITY_NORMAL, + KEY_ENTER_COMMAND, + KEY_SPACE_COMMAND, + LexicalEditor, + TextNode +} from "lexical"; +import {$getTextNodeFromSelection} from "../utils/selection"; +import {$createLinkNode, LinkNode} from "@lexical/link"; + + +function isLinkText(text: string): boolean { + const lower = text.toLowerCase(); + if (!lower.startsWith('http')) { + return false; + } + + const linkRegex = /(http|https):\/\/(\S+)\.\S+$/; + return linkRegex.test(text); +} + + +function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, editor: LexicalEditor) { + const selectionRange = selection.getStartEndPoints(); + if (!selectionRange) { + return; + } + + const cursorPoint = selectionRange[0].offset; + const nodeText = node.getTextContent(); + const rTrimText = nodeText.slice(0, cursorPoint); + const priorSpaceIndex = rTrimText.lastIndexOf(' '); + const startIndex = priorSpaceIndex + 1; + const textSegment = nodeText.slice(startIndex, cursorPoint); + + if (!isLinkText(textSegment)) { + return; + } + + editor.update(() => { + const linkNode: LinkNode = $createLinkNode(textSegment); + linkNode.append(new TextNode(textSegment)); + + const splits = node.splitText(startIndex, cursorPoint); + const targetIndex = splits.length === 3 ? 1 : 0; + const targetText = splits[targetIndex]; + if (targetText) { + targetText.replace(linkNode); + } + }); +} + + +export function registerAutoLinks(editor: LexicalEditor): () => void { + + const handler = (payload: KeyboardEvent): boolean => { + const selection = $getSelection(); + const textNode = $getTextNodeFromSelection(selection); + if (textNode && selection) { + handlePotentialLinkEvent(textNode, selection, editor); + } + + return false; + }; + + const unregisterSpace = editor.registerCommand(KEY_SPACE_COMMAND, handler, COMMAND_PRIORITY_NORMAL); + const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, handler, COMMAND_PRIORITY_NORMAL); + + return (): void => { + unregisterSpace(); + unregisterEnter(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/services/common-events.ts b/resources/js/wysiwyg/services/common-events.ts index 16522d66b71..2ffa722e40c 100644 --- a/resources/js/wysiwyg/services/common-events.ts +++ b/resources/js/wysiwyg/services/common-events.ts @@ -1,4 +1,4 @@ -import {LexicalEditor} from "lexical"; +import {$getSelection, LexicalEditor} from "lexical"; import { appendHtmlToEditor, focusEditor, @@ -40,4 +40,16 @@ export function listen(editor: LexicalEditor): void { window.$events.listen('editor::focus', () => { focusEditor(editor); }); + + let changeFromLoading = true; + editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { + // Emit change event to component system (for draft detection) on actual user content change + if (dirtyElements.size > 0 || dirtyLeaves.size > 0) { + if (changeFromLoading) { + changeFromLoading = false; + } else { + window.$events.emit('editor-html-change', ''); + } + } + }); } diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 6a1345fac6d..ff6117b2b87 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -3,7 +3,7 @@ import { $createParagraphNode, $getSelection, $isDecoratorNode, - COMMAND_PRIORITY_LOW, + COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, @@ -13,9 +13,10 @@ import { import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {getLastSelection} from "../utils/selection"; -import {$getNearestNodeBlockParent} from "../utils/nodes"; +import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes"; import {$setInsetForSelection} from "../utils/lists"; import {$isListItemNode} from "@lexical/list"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; function isSingleSelectedNode(nodes: LexicalNode[]): boolean { if (nodes.length === 1) { @@ -28,6 +29,10 @@ function isSingleSelectedNode(nodes: LexicalNode[]): boolean { return false; } +/** + * Delete the current node in the selection if the selection contains a single + * selected node (like image, media etc...). + */ function deleteSingleSelectedNode(editor: LexicalEditor) { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { @@ -37,6 +42,10 @@ function deleteSingleSelectedNode(editor: LexicalEditor) { } } +/** + * Insert a new empty node after the selection if the selection contains a single + * selected node (like image, media etc...). + */ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { @@ -58,11 +67,108 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve return false; } +/** + * Insert a new node after a details node, if inside a details node that's + * the last element, and if the cursor is at the last block within the details node. + */ +function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const scenario = getDetailsScenario(editor); + if (scenario === null || scenario.detailsSibling) { + return false; + } + + editor.update(() => { + const newParagraph = $createParagraphNode(); + scenario.parentDetails.insertAfter(newParagraph); + newParagraph.select(); + }); + event?.preventDefault(); + + return true; +} + +/** + * If within a details block, move after it, creating a new node if required, if we're on + * the last empty block element within the details node. + */ +function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const scenario = getDetailsScenario(editor); + if (scenario === null) { + return false; + } + + if (scenario.parentBlock.getTextContent() !== '') { + return false; + } + + event?.preventDefault() + + const nextSibling = scenario.parentDetails.getNextSibling(); + editor.update(() => { + if (nextSibling) { + nextSibling.selectStart(); + } else { + const newParagraph = $createParagraphNode(); + scenario.parentDetails.insertAfter(newParagraph); + newParagraph.select(); + } + scenario.parentBlock.remove(); + }); + + return true; +} + +/** + * Get the common nodes used for a details node scenario, relative to current selection. + * Returns null if not found, or if the parent block is not the last in the parent details node. + */ +function getDetailsScenario(editor: LexicalEditor): { + parentDetails: DetailsNode; + parentBlock: LexicalNode; + detailsSibling: LexicalNode | null +} | null { + const selection = getLastSelection(editor); + const firstNode = selection?.getNodes()[0]; + if (!firstNode) { + return null; + } + + const block = $getNearestNodeBlockParent(firstNode); + const details = $getParentOfType(firstNode, $isDetailsNode); + if (!$isDetailsNode(details) || block === null) { + return null; + } + + if (block.getKey() !== details.getLastChild()?.getKey()) { + return null; + } + + const nextSibling = details.getNextSibling(); + return { + parentDetails: details, + parentBlock: block, + detailsSibling: nextSibling, + } +} + +function $isSingleListItem(nodes: LexicalNode[]): boolean { + if (nodes.length !== 1) { + return false; + } + + const node = nodes[0]; + return $isListItemNode(node) || $isListItemNode(node.getParent()); +} + +/** + * Inset the nodes within selection when a range of nodes is selected + * or if a list node is selected. + */ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const change = event?.shiftKey ? -40 : 40; const selection = $getSelection(); const nodes = selection?.getNodes() || []; - if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) { + if (nodes.length > 1 || $isSingleListItem(nodes)) { editor.update(() => { $setInsetForSelection(editor, change); }); @@ -85,17 +191,23 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void { }, COMMAND_PRIORITY_LOW); const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => { - return insertAfterSingleSelectedNode(context.editor, event); + return insertAfterSingleSelectedNode(context.editor, event) + || moveAfterDetailsOnEmptyLine(context.editor, event); }, COMMAND_PRIORITY_LOW); const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => { return handleInsetOnTab(context.editor, event); }, COMMAND_PRIORITY_LOW); + const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => { + return insertAfterDetails(context.editor, event); + }, COMMAND_PRIORITY_LOW); + return () => { unregisterBackspace(); unregisterDelete(); unregisterEnter(); unregisterTab(); + unregisterDown(); }; } \ 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 77223dac3f7..5e32005393e 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -11,8 +11,9 @@ import { } from "lexical"; import redoIcon from "@icons/editor/redo.svg"; import sourceIcon from "@icons/editor/source-view.svg"; -import {getEditorContentAsHtml} from "../../../utils/actions"; import fullscreenIcon from "@icons/editor/fullscreen.svg"; +import aboutIcon from "@icons/editor/about.svg"; +import {getEditorContentAsHtml} from "../../../utils/actions"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -80,4 +81,16 @@ export const fullscreen: EditorButtonDefinition = { isActive(selection, context: EditorUiContext) { return context.containerDOM.classList.contains('fullscreen'); } +}; + +export const about: EditorButtonDefinition = { + label: 'About the editor', + icon: aboutIcon, + async action(context: EditorUiContext, button: EditorButton) { + const modal = context.manager.createModal('about'); + modal.show({}); + }, + isActive(selection, context: EditorUiContext) { + return false; + } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index f9c029ff14c..6612c0dc451 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -19,6 +19,9 @@ import editIcon from "@icons/edit.svg"; import diagramIcon from "@icons/editor/diagram.svg"; import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import detailsIcon from "@icons/editor/details.svg"; +import detailsToggleIcon from "@icons/editor/details-toggle.svg"; +import tableDeleteIcon from "@icons/editor/table-delete.svg"; +import tagIcon from "@icons/tag.svg"; import mediaIcon from "@icons/editor/media.svg"; import {$createDetailsNode, $isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {$isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode"; @@ -29,7 +32,7 @@ import { } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; -import {$showImageForm, $showLinkForm} from "../forms/objects"; +import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { @@ -216,4 +219,58 @@ export const details: EditorButtonDefinition = { isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isDetailsNode); } +} + +export const detailsEditLabel: EditorButtonDefinition = { + label: 'Edit label', + icon: tagIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + $showDetailsForm(details, context); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } +} + +export const detailsToggle: EditorButtonDefinition = { + label: 'Toggle open/closed', + icon: detailsToggleIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + details.setOpen(!details.getOpen()); + context.manager.triggerLayoutUpdate(); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } +} + +export const detailsUnwrap: EditorButtonDefinition = { + label: 'Unwrap', + icon: tableDeleteIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + const children = details.getChildren(); + for (const child of children) { + details.insertBefore(child); + } + details.remove(); + context.manager.triggerLayoutUpdate(); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/controls.ts b/resources/js/wysiwyg/ui/defaults/forms/controls.ts index fc461f6626f..8e7219d6749 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/controls.ts @@ -1,6 +1,7 @@ import {EditorFormDefinition} from "../../framework/forms"; -import {EditorUiContext} from "../../framework/core"; +import {EditorUiContext, EditorUiElement} from "../../framework/core"; import {setEditorContentFromHtml} from "../../../utils/actions"; +import {ExternalContent} from "../../framework/blocks/external-content"; export const source: EditorFormDefinition = { submitText: 'Save', @@ -15,4 +16,18 @@ export const source: EditorFormDefinition = { type: 'textarea', }, ], +}; + +export const about: EditorFormDefinition = { + submitText: 'Close', + async action() { + return true; + }, + fields: [ + { + build(): EditorUiElement { + return new ExternalContent('/help/wysiwyg'); + } + } + ], }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index f00a08bb5f5..21d333c3aa2 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -19,6 +19,7 @@ import searchIcon from "@icons/search.svg"; import {showLinkSelector} from "../../../utils/links"; import {LinkField} from "../../framework/blocks/link-field"; import {insertOrUpdateLink} from "../../../utils/formats"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; export function $showImageForm(image: ImageNode, context: EditorUiContext) { const imageModal: EditorFormModal = context.manager.createModal('image'); @@ -262,4 +263,37 @@ export const media: EditorFormDefinition = { } }, ], +}; + +export function $showDetailsForm(details: DetailsNode|null, context: EditorUiContext) { + const linkModal = context.manager.createModal('details'); + if (!details) { + return; + } + + linkModal.show({ + summary: details.getSummary() + }); +} + +export const details: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + context.editor.update(() => { + const node = $getNodeFromSelection($getSelection(), $isDetailsNode); + const summary = (formData.get('summary') || '').toString().trim(); + if ($isDetailsNode(node)) { + node.setSummary(summary); + } + }); + + return true; + }, + fields: [ + { + label: 'Toggle label', + name: 'summary', + type: 'text', + }, + ], }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts index c4392377828..830a42935c7 100644 --- a/resources/js/wysiwyg/ui/defaults/modals.ts +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -1,6 +1,6 @@ import {EditorFormModalDefinition} from "../framework/modals"; -import {image, link, media} from "./forms/objects"; -import {source} from "./forms/controls"; +import {details, image, link, media} from "./forms/objects"; +import {about, source} from "./forms/controls"; import {cellProperties, rowProperties, tableProperties} from "./forms/tables"; export const modals: Record = { @@ -32,4 +32,12 @@ export const modals: Record = { title: 'Table Properties', form: tableProperties, }, + details: { + title: 'Edit collapsible block', + form: details, + }, + about: { + title: 'About the WYSIWYG Editor', + form: about, + } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts similarity index 84% rename from resources/js/wysiwyg/ui/toolbars.ts rename to resources/js/wysiwyg/ui/defaults/toolbars.ts index 35146e5a440..61baa3c3260 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -1,12 +1,12 @@ -import {EditorButton} from "./framework/buttons"; -import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core"; -import {EditorFormatMenu} from "./framework/blocks/format-menu"; -import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; -import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; -import {EditorColorPicker} from "./framework/blocks/color-picker"; -import {EditorTableCreator} from "./framework/blocks/table-creator"; -import {EditorColorButton} from "./framework/blocks/color-button"; -import {EditorOverflowContainer} from "./framework/blocks/overflow-container"; +import {EditorButton} from "../framework/buttons"; +import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "../framework/core"; +import {EditorFormatMenu} from "../framework/blocks/format-menu"; +import {FormatPreviewButton} from "../framework/blocks/format-preview-button"; +import {EditorDropdownButton} from "../framework/blocks/dropdown-button"; +import {EditorColorPicker} from "../framework/blocks/color-picker"; +import {EditorTableCreator} from "../framework/blocks/table-creator"; +import {EditorColorButton} from "../framework/blocks/color-button"; +import {EditorOverflowContainer} from "../framework/blocks/overflow-container"; import { cellProperties, clearTableFormatting, copyColumn, @@ -29,8 +29,8 @@ import { rowProperties, splitCell, table, tableProperties -} from "./defaults/buttons/tables"; -import {fullscreen, redo, source, undo} from "./defaults/buttons/controls"; +} from "./buttons/tables"; +import {about, fullscreen, redo, source, undo} from "./buttons/controls"; import { blockquote, dangerCallout, h2, @@ -41,7 +41,7 @@ import { paragraph, successCallout, warningCallout -} from "./defaults/buttons/block-formats"; +} from "./buttons/block-formats"; import { bold, clearFormating, code, highlightColor, @@ -50,7 +50,7 @@ import { superscript, textColor, underline -} from "./defaults/buttons/inline-formats"; +} from "./buttons/inline-formats"; import { alignCenter, alignJustify, @@ -58,27 +58,27 @@ import { alignRight, directionLTR, directionRTL -} from "./defaults/buttons/alignments"; +} from "./buttons/alignments"; import { bulletList, indentDecrease, indentIncrease, numberList, taskList -} from "./defaults/buttons/lists"; +} from "./buttons/lists"; import { codeBlock, - details, + details, detailsEditLabel, detailsToggle, detailsUnwrap, diagram, diagramManager, editCodeBlock, horizontalRule, image, link, media, unlink -} from "./defaults/buttons/objects"; -import {el} from "../utils/dom"; -import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu"; -import {EditorSeparator} from "./framework/blocks/separator"; +} from "./buttons/objects"; +import {el} from "../../utils/dom"; +import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu"; +import {EditorSeparator} from "../framework/blocks/separator"; export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement { @@ -149,8 +149,8 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai new EditorOverflowContainer(4, [ new EditorButton(link), - new EditorDropdownButton({button: table, direction: 'vertical'}, [ - new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true}, [ + new EditorDropdownButton({button: table, direction: 'vertical', showAside: false}, [ + new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true, showAside: true}, [ new EditorTableCreator(), ]), new EditorSeparator(), @@ -201,6 +201,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai // Meta elements new EditorOverflowContainer(3, [ new EditorButton(source), + new EditorButton(about), new EditorButton(fullscreen), // Test @@ -253,4 +254,12 @@ export function getTableToolbarContent(): EditorUiElement[] { new EditorButton(deleteColumn), ]), ]; +} + +export function getDetailsToolbarContent(): EditorUiElement[] { + return [ + new EditorButton(detailsEditLabel), + new EditorButton(detailsToggle), + new EditorButton(detailsUnwrap), + ]; } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts index 30dd237f60f..2aec7c3352e 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts @@ -16,6 +16,7 @@ export class EditorButtonWithMenu extends EditorContainerUiElement { button: {label: 'Menu', icon: caretDownIcon}, showOnHover: false, direction: 'vertical', + showAside: false, }, menuItems); this.addChildren(this.dropdownButton); } diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index cba141f6c77..d7f02d5732b 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -7,12 +7,14 @@ import {EditorMenuButton} from "./menu-button"; export type EditorDropdownButtonOptions = { showOnHover?: boolean; direction?: 'vertical'|'horizontal'; + showAside?: boolean; button: EditorBasicButtonDefinition|EditorButton; }; const defaultOptions: EditorDropdownButtonOptions = { showOnHover: false, direction: 'horizontal', + showAside: undefined, button: {label: 'Menu'}, } @@ -65,6 +67,7 @@ export class EditorDropdownButton extends EditorContainerUiElement { handleDropdown({toggle: button, menu : menu, showOnHover: this.options.showOnHover, + showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'), onOpen : () => { this.open = true; this.getContext().manager.triggerStateUpdateForElement(this.button); diff --git a/resources/js/wysiwyg/ui/framework/blocks/external-content.ts b/resources/js/wysiwyg/ui/framework/blocks/external-content.ts new file mode 100644 index 00000000000..b53c43e5511 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/external-content.ts @@ -0,0 +1,29 @@ +import {EditorUiElement} from "../core"; +import {el} from "../../../utils/dom"; + +export class ExternalContent extends EditorUiElement { + + /** + * The URL for HTML to be loaded from. + */ + protected url: string = ''; + + constructor(url: string) { + super(); + this.url = url; + } + + buildDOM(): HTMLElement { + const wrapper = el('div', { + class: 'editor-external-content', + }); + + window.$http.get(this.url).then(resp => { + if (typeof resp.data === 'string') { + wrapper.innerHTML = resp.data; + } + }); + + return wrapper; + } +} diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts index e8cef3c8d20..ccced685867 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -1,20 +1,48 @@ - - - interface HandleDropdownParams { toggle: HTMLElement; menu: HTMLElement; showOnHover?: boolean, onOpen?: Function | undefined; onClose?: Function | undefined; + showAside?: boolean; +} + +function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean) { + const toggleRect = toggle.getBoundingClientRect(); + const menuBounds = menu.getBoundingClientRect(); + + menu.style.position = 'fixed'; + + if (showAside) { + let targetLeft = toggleRect.right; + const isRightOOB = toggleRect.right + menuBounds.width > window.innerWidth; + if (isRightOOB) { + targetLeft = Math.max(toggleRect.left - menuBounds.width, 0); + } + + menu.style.top = toggleRect.top + 'px'; + menu.style.left = targetLeft + 'px'; + } else { + const isRightOOB = toggleRect.left + menuBounds.width > window.innerWidth; + let targetLeft = toggleRect.left; + if (isRightOOB) { + targetLeft = Math.max(toggleRect.right - menuBounds.width, 0); + } + + menu.style.top = toggleRect.bottom + 'px'; + menu.style.left = targetLeft + 'px'; + } } export function handleDropdown(options: HandleDropdownParams) { - const {menu, toggle, onClose, onOpen, showOnHover} = options; + const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options; let clickListener: Function|null = null; const hide = () => { menu.hidden = true; + menu.style.removeProperty('position'); + menu.style.removeProperty('left'); + menu.style.removeProperty('top'); if (clickListener) { window.removeEventListener('click', clickListener as EventListener); } @@ -25,6 +53,7 @@ export function handleDropdown(options: HandleDropdownParams) { const show = () => { menu.hidden = false + positionMenu(menu, toggle, Boolean(showAside)); clickListener = (event: MouseEvent) => { if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) { hide(); @@ -44,5 +73,18 @@ export function handleDropdown(options: HandleDropdownParams) { toggle.addEventListener('mouseenter', toggleShowing); } - menu.parentElement?.addEventListener('mouseleave', hide); + menu.parentElement?.addEventListener('mouseleave', (event: MouseEvent) => { + + // Prevent mouseleave hiding if withing the same bounds of the toggle. + // Avoids hiding in the event the mouse is interrupted by a high z-index + // item like a browser scrollbar. + const toggleBounds = toggle.getBoundingClientRect(); + const withinX = event.clientX <= toggleBounds.right && event.clientX >= toggleBounds.left; + const withinY = event.clientY <= toggleBounds.bottom && event.clientY >= toggleBounds.top; + const withinToggle = withinX && withinY; + + if (!withinToggle) { + hide(); + } + }); } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 185cd5dccd0..0f501d9faae 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,7 +1,7 @@ import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; -import {BaseSelection, LexicalEditor} from "lexical"; +import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; @@ -231,6 +231,22 @@ export class EditorUIManager { }); } editor.registerDecoratorListener(domDecorateListener); + + // Watch for changes to update local state + editor.registerUpdateListener(({editorState, prevEditorState}) => { + // Watch for selection changes to update the UI on change + // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit + // for all selection changes, so this proved more reliable. + const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false); + if (selectionChange) { + editor.update(() => { + const selection = $getSelection(); + this.triggerStateUpdate({ + editor, selection, + }); + }); + } + }); } protected setupEventListeners(context: EditorUiContext) { diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 3811f44b9bf..fda37085ef3 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,10 +1,10 @@ import {LexicalEditor} from "lexical"; import { - getCodeToolbarContent, + getCodeToolbarContent, getDetailsToolbarContent, getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar, getTableToolbarContent -} from "./toolbars"; +} from "./defaults/toolbars"; import {EditorUIManager} from "./framework/manager"; import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; @@ -56,7 +56,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro selector: '.editor-code-block-wrap', content: getCodeToolbarContent(), }); - manager.registerContextToolbar('table', { selector: 'td,th', content: getTableToolbarContent(), @@ -64,6 +63,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro return originalTarget.closest('table') as HTMLTableElement; } }); + manager.registerContextToolbar('details', { + selector: 'details', + content: getDetailsToolbarContent(), + }); // Register image decorator listener manager.registerDecoratorType('code', CodeBlockDecorator); diff --git a/resources/js/wysiwyg/utils/__tests__/lists.test.ts b/resources/js/wysiwyg/utils/__tests__/lists.test.ts new file mode 100644 index 00000000000..20dcad24098 --- /dev/null +++ b/resources/js/wysiwyg/utils/__tests__/lists.test.ts @@ -0,0 +1,124 @@ +import { + createTestContext, destroyFromContext, + dispatchKeydownEventForNode, expectNodeShapeToMatch, +} from "lexical/__tests__/utils"; +import { + $createParagraphNode, $getRoot, LexicalEditor, LexicalNode, + ParagraphNode, +} from "lexical"; +import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {EditorUiContext} from "../../ui/framework/core"; +import {$htmlToBlockNodes} from "../nodes"; +import {ListItemNode, ListNode} from "@lexical/list"; +import {$nestListItem, $unnestListItem} from "../lists"; + +describe('List Utils', () => { + + let context!: EditorUiContext; + let editor!: LexicalEditor; + + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + }); + + afterEach(() => { + destroyFromContext(context); + }); + + describe('$nestListItem', () => { + test('nesting handles child items to leave at the same level', () => { + const input = `
      +
    • Inner A
    • +
    • Inner B
        +
      • Inner C
      • +
    • +
    `; + let list!: ListNode; + + editor.updateAndCommit(() => { + $getRoot().append(...$htmlToBlockNodes(editor, input)); + list = $getRoot().getFirstChild() as ListNode; + }); + + editor.updateAndCommit(() => { + $nestListItem(list.getChildren()[1] as ListItemNode); + }); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [ + { + type: 'listitem', + children: [ + {text: 'Inner A'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner B'}]}, + {type: 'listitem', children: [{text: 'Inner C'}]}, + ] + } + ] + }, + ] + } + ]); + }); + }); + + describe('$unnestListItem', () => { + test('middle in nested list converts to new parent item at same place', () => { + const input = `
      +
    • Nested list:
        +
      • Inner A
      • +
      • Inner B
      • +
      • Inner C
      • +
    • +
    `; + let innerList!: ListNode; + + editor.updateAndCommit(() => { + $getRoot().append(...$htmlToBlockNodes(editor, input)); + innerList = (($getRoot().getFirstChild() as ListNode).getFirstChild() as ListItemNode).getLastChild() as ListNode; + }); + + editor.updateAndCommit(() => { + $unnestListItem(innerList.getChildren()[1] as ListItemNode); + }); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [ + { + type: 'listitem', + children: [ + {text: 'Nested list:'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner A'}]}, + ], + } + ], + }, + { + type: 'listitem', + children: [ + {text: 'Inner B'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner C'}]}, + ], + } + ], + } + ] + } + ]); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 646f341c2ba..005b05f9816 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -1,4 +1,4 @@ -import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; +import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical"; import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection"; import {nodeHasInset} from "./nodes"; import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; @@ -10,6 +10,9 @@ export function $nestListItem(node: ListItemNode): ListItemNode { return node; } + const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null; + const nodeChildItems = nodeChildList?.getChildren() || []; + const listItems = list.getChildren() as ListItemNode[]; const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey()); const isFirst = nodeIndex === 0; @@ -27,6 +30,13 @@ export function $nestListItem(node: ListItemNode): ListItemNode { node.remove(); } + if (nodeChildList) { + for (const child of nodeChildItems) { + newListItem.insertAfter(child); + } + nodeChildList.remove(); + } + return newListItem; } @@ -38,6 +48,8 @@ export function $unnestListItem(node: ListItemNode): ListItemNode { return node; } + const laterSiblings = node.getNextSiblings(); + parentListItem.insertAfter(node); if (list.getChildren().length === 0) { list.remove(); @@ -47,6 +59,16 @@ export function $unnestListItem(node: ListItemNode): ListItemNode { parentListItem.remove(); } + if (laterSiblings.length > 0) { + const childList = $createListNode(list.getListType()); + childList.append(...laterSiblings); + node.append(childList); + } + + if (list.getChildrenSize() === 0) { + list.remove(); + } + return node; } @@ -93,6 +115,7 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[ export function $setInsetForSelection(editor: LexicalEditor, change: number): void { const selection = $getSelection(); + const selectionBounds = selection?.getStartEndPoints(); const listItemsInSelection = getListItemsForSelection(selection); const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null); @@ -110,7 +133,19 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo alteredListItems.reverse(); } - $selectNodes(alteredListItems); + if (alteredListItems.length === 1 && selectionBounds) { + // Retain selection range if moving just one item + const listItem = alteredListItems[0] as ListItemNode; + let child = listItem.getChildren()[0] as TextNode; + if (!child) { + child = $createTextNode(''); + listItem.append(child); + } + child.select(selectionBounds[0].offset, selectionBounds[1].offset); + } else { + $selectNodes(alteredListItems); + } + return; } diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 28e729e92ae..167ab32adca 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -51,6 +51,10 @@ export function $getNodeFromSelection(selection: BaseSelection | null, matcher: return null; } +export function $getTextNodeFromSelection(selection: BaseSelection | null): TextNode|null { + return $getNodeFromSelection(selection, $isTextNode) as TextNode|null; +} + export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean { if (!selection) { return false; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index e273f1942d5..2446c141670 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -8,18 +8,28 @@ // Main UI elements .editor-container { - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); position: relative; &.fullscreen { z-index: 500; } } + .editor-toolbar-main { display: flex; flex-wrap: wrap; justify-content: center; border-top: 1px solid #DDD; border-bottom: 1px solid #DDD; + @include mixins.lightDark(border-color, #DDD, #000); +} + +@include mixins.smaller-than(vars.$bp-xl) { + .editor-toolbar-main { + overflow-x: scroll; + flex-wrap: nowrap; + justify-content: start; + } } body.editor-is-fullscreen { @@ -38,6 +48,7 @@ body.editor-is-fullscreen { .editor-content-wrap { position: relative; overflow-y: scroll; + padding-inline: vars.$s; flex: 1; } @@ -46,6 +57,7 @@ body.editor-is-fullscreen { font-size: 12px; padding: 4px; color: #444; + @include mixins.lightDark(color, #444, #999); border-radius: 4px; display: flex; align-items: center; @@ -54,6 +66,7 @@ body.editor-is-fullscreen { } .editor-button:hover { background-color: #EEE; + @include mixins.lightDark(background-color, #EEE, #333); cursor: pointer; color: #000; } @@ -63,7 +76,7 @@ body.editor-is-fullscreen { opacity: .6; } .editor-button-active, .editor-button-active:hover { - background-color: #ceebff; + @include mixins.lightDark(background-color, #ceebff, #444); color: #000; } .editor-button-long { @@ -75,7 +88,7 @@ body.editor-is-fullscreen { } .editor-button-text { font-weight: 400; - color: #000; + @include mixins.lightDark(color, #000, #AAA); font-size: 14px; flex: 1; padding-inline-end: 4px; @@ -126,7 +139,8 @@ body.editor-is-fullscreen { } } &:hover { - outline: 1px solid #DDD; + outline: 1px solid; + @include mixins.lightDark(outline-color, #DDD, #111); outline-offset: -3px; } } @@ -137,11 +151,14 @@ body.editor-is-fullscreen { } .editor-dropdown-menu { position: absolute; - background-color: #FFF; - box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15); + border: 1px solid; + @include mixins.lightDark(background-color, #FFF, #292929); + @include mixins.lightDark(border-color, #FFF, #333); + @include mixins.lightDark(box-shadow, 0 0 6px 0 rgba(0, 0, 0, 0.15), 0 1px 4px 0 rgba(0, 0, 0, 0.4)); z-index: 99; display: flex; flex-direction: row; + border-radius: 3px; } .editor-dropdown-menu-vertical { display: flex; @@ -163,8 +180,8 @@ body.editor-is-fullscreen { .editor-separator { display: block; height: 1px; - background-color: #DDD; opacity: .8; + @include mixins.lightDark(background-color, #DDD, #000); } .editor-format-menu-toggle { @@ -199,6 +216,7 @@ body.editor-is-fullscreen { display: flex; border-inline: 1px solid #DDD; padding-inline: 4px; + @include mixins.lightDark(border-color, #DDD, #000); &:first-child { border-inline-start: none; } @@ -212,11 +230,12 @@ body.editor-is-fullscreen { .editor-context-toolbar { position: fixed; - background-color: #FFF; border: 1px solid #DDD; + @include mixins.lightDark(background-color, #FFF, #222); + @include mixins.lightDark(border-color, #DDD, #333); + @include mixins.lightDark(box-shadow, 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 1px 4px 0 rgba(0, 0, 0, 0.4)); padding: .2rem; border-radius: 4px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12); display: flex; flex-direction: row; &:before { @@ -226,9 +245,10 @@ body.editor-is-fullscreen { width: 8px; height: 8px; position: absolute; - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); border-top: 1px solid #DDD; border-left: 1px solid #DDD; + @include mixins.lightDark(border-color, #DDD, #333); transform: rotate(45deg); left: 50%; margin-left: -4px; @@ -252,10 +272,13 @@ body.editor-is-fullscreen { height: 100%; } .editor-modal { - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); border-radius: 4px; overflow: hidden; box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); + margin: vars.$xs; + max-height: 100%; + overflow-y: auto; } .editor-modal-header { display: flex; @@ -314,7 +337,8 @@ body.editor-is-fullscreen { display: flex; } .editor-table-creator-cell { - border: 1px solid #DDD; + border: 1px solid; + @include mixins.lightDark(border-color, #DDD, #000); width: 15px; height: 15px; cursor: pointer; @@ -326,6 +350,13 @@ body.editor-is-fullscreen { text-align: center; padding: 0.2em; } +.editor-external-content { + min-width: 500px; + min-height: 500px; + h4:first-child { + margin-top: 0; + } +} // In-editor elements .editor-image-wrap { @@ -347,7 +378,7 @@ body.editor-is-fullscreen { height: 10px; border: 2px solid var(--editor-color-primary); z-index: 3; - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #000); user-select: none; &.nw { inset-inline-start: -5px; @@ -470,18 +501,29 @@ body.editor-is-fullscreen { /** * Form elements */ +$inputWidth: 260px; + .editor-form-field-wrapper { margin-bottom: .5rem; } .editor-form-field-input { display: block; - width: 100%; - min-width: 250px; - border: 1px solid #DDD; + width: $inputWidth; + min-width: 100px; + max-width: 100%; + border: 1px solid; + @include mixins.lightDark(border-color, #DDD, #000); padding: .5rem; border-radius: 4px; - color: #444; + @include mixins.lightDark(color, #444, #BBB); } + +@include mixins.smaller-than(vars.$bp-xs) { + .editor-form-field-input { + min-width: 160px; + } +} + textarea.editor-form-field-input { font-family: var(--font-code); width: 350px; @@ -554,10 +596,21 @@ textarea.editor-form-field-input { align-items: stretch; gap: .25rem; } + +@include mixins.smaller-than(vars.$bp-m) { + .editor-form-tab-container { + flex-direction: column; + gap: .5rem; + } + .editor-form-tab-controls { + flex-direction: row; + } +} + .editor-form-tab-control { font-weight: bold; font-size: 14px; - color: #444; + @include mixins.lightDark(color, #444, #666); border-bottom: 2px solid transparent; position: relative; cursor: pointer; @@ -565,7 +618,7 @@ textarea.editor-form-field-input { text-align: start; &[aria-selected="true"] { border-color: var(--editor-color-primary); - color: var(--editor-color-primary); + color: var(--editor-color-primary) !important; } &[aria-selected="true"]:after, &:hover:after { background-color: var(--editor-color-primary); @@ -580,7 +633,8 @@ textarea.editor-form-field-input { } } .editor-form-tab-contents { - width: 360px; + width: $inputWidth; + max-width: 100%; } .editor-action-input-container { display: flex; @@ -591,6 +645,9 @@ textarea.editor-form-field-input { .editor-button { margin-bottom: 12px; } + input { + width: $inputWidth - 40px; + } } // Editor theme styles diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 17bcfcfbf11..45e58ffc865 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -26,6 +26,7 @@ width: 100%; border-radius: 8px; box-shadow: vars.$bs-card; + min-width: 300px; @include mixins.lightDark(background-color, #FFF, #333) } diff --git a/resources/views/help/tinymce.blade.php b/resources/views/help/tinymce.blade.php new file mode 100644 index 00000000000..8ff59c8d664 --- /dev/null +++ b/resources/views/help/tinymce.blade.php @@ -0,0 +1,146 @@ +@extends('layouts.plain') +@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '')) + +@section('content') +
    + +

    {{ trans('editor.editor_license') }}

    +

    + {!! trans('editor.editor_tiny_license', ['tinyLink' => 'TinyMCE']) !!} +
    + {{ trans('editor.editor_tiny_license_link') }} +

    + +

    {{ trans('editor.shortcuts') }}

    + +

    {{ trans('editor.shortcuts_intro') }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    + Ctrl+1
    + Ctrl+2
    + Ctrl+3
    + Ctrl+4 +
    + Cmd+1
    + Cmd+2
    + Cmd+3
    + Cmd+4 +
    + {{ trans('editor.header_large') }}
    + {{ trans('editor.header_medium') }}
    + {{ trans('editor.header_small') }}
    + {{ trans('editor.header_tiny') }} +
    + Ctrl+5
    + Ctrl+D +
    + Cmd+5
    + Cmd+D +
    {{ trans('editor.paragraph') }}
    + Ctrl+6
    + Ctrl+Q +
    + Cmd+6
    + Cmd+Q +
    {{ trans('editor.blockquote') }}
    + Ctrl+7
    + Ctrl+E +
    + Cmd+7
    + Cmd+E +
    {{ trans('editor.insert_code_block') }}
    + Ctrl+8
    + Ctrl+Shift+E +
    + Cmd+8
    + Cmd+Shift+E +
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 + {{ trans('editor.callouts') }}
    + {{ trans('editor.callouts_cycle') }} +
    + Ctrl+O
    + Ctrl+P +
    + Cmd+O
    + Cmd+P +
    + {{ trans('editor.list_numbered') }}
    + {{ trans('editor.list_bullet') }} +
    + Ctrl+Shift+K + + Cmd+Shift+K + {{ trans('editor.link_selector') }}
    + +
    +@endsection + diff --git a/resources/views/help/wysiwyg.blade.php b/resources/views/help/wysiwyg.blade.php index 8ff59c8d664..4fc00b0e1e6 100644 --- a/resources/views/help/wysiwyg.blade.php +++ b/resources/views/help/wysiwyg.blade.php @@ -1,146 +1,138 @@ -@extends('layouts.plain') -@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '')) +

    {{ trans('editor.shortcuts') }}

    -@section('content') -
    - -

    {{ trans('editor.editor_license') }}

    -

    - {!! trans('editor.editor_tiny_license', ['tinyLink' => 'TinyMCE']) !!} -
    - {{ trans('editor.editor_tiny_license_link') }} -

    - -

    {{ trans('editor.shortcuts') }}

    - -

    {{ trans('editor.shortcuts_intro') }}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    - Ctrl+1
    - Ctrl+2
    - Ctrl+3
    - Ctrl+4 -
    - Cmd+1
    - Cmd+2
    - Cmd+3
    - Cmd+4 -
    - {{ trans('editor.header_large') }}
    - {{ trans('editor.header_medium') }}
    - {{ trans('editor.header_small') }}
    - {{ trans('editor.header_tiny') }} -
    - Ctrl+5
    - Ctrl+D -
    - Cmd+5
    - Cmd+D -
    {{ trans('editor.paragraph') }}
    - Ctrl+6
    - Ctrl+Q -
    - Cmd+6
    - Cmd+Q -
    {{ trans('editor.blockquote') }}
    - Ctrl+7
    - Ctrl+E -
    - Cmd+7
    - Cmd+E -
    {{ trans('editor.insert_code_block') }}
    - Ctrl+8
    - Ctrl+Shift+E -
    - Cmd+8
    - Cmd+Shift+E -
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 - {{ trans('editor.callouts') }}
    - {{ trans('editor.callouts_cycle') }} -
    - Ctrl+O
    - Ctrl+P -
    - Cmd+O
    - Cmd+P -
    - {{ trans('editor.list_numbered') }}
    - {{ trans('editor.list_bullet') }} -
    - Ctrl+Shift+K - - Cmd+Shift+K - {{ trans('editor.link_selector') }}
    - -
    -@endsection +

    {{ trans('editor.shortcuts_intro') }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    + Ctrl+1
    + Ctrl+2
    + Ctrl+3
    + Ctrl+4 +
    + Cmd+1
    + Cmd+2
    + Cmd+3
    + Cmd+4 +
    + {{ trans('editor.header_large') }}
    + {{ trans('editor.header_medium') }}
    + {{ trans('editor.header_small') }}
    + {{ trans('editor.header_tiny') }} +
    + Ctrl+5
    + Ctrl+D +
    + Cmd+5
    + Cmd+D +
    {{ trans('editor.paragraph') }}
    + Ctrl+6
    + Ctrl+Q +
    + Cmd+6
    + Cmd+Q +
    {{ trans('editor.blockquote') }}
    + Ctrl+7
    + Ctrl+E +
    + Cmd+7
    + Cmd+E +
    {{ trans('editor.insert_code_block') }}
    + Ctrl+8
    + Ctrl+Shift+E +
    + Cmd+8
    + Cmd+Shift+E +
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 + {{ trans('editor.callouts') }}
    + {{ trans('editor.callouts_cycle') }} +
    + Ctrl+O
    + Ctrl+P +
    + Cmd+O
    + Cmd+P +
    + {{ trans('editor.list_numbered') }}
    + {{ trans('editor.list_bullet') }} +
    + Ctrl+Shift+K + + Cmd+Shift+K + {{ trans('editor.link_selector') }}
    +

    {{ trans('editor.editor_license') }}

    +

    + {!! trans('editor.editor_lexical_license', ['lexicalLink' => 'Lexical']) !!} +
    + Copyright (c) Meta Platforms, Inc. and affiliates. +
    + {{ trans('editor.editor_lexical_license_link') }} +

    \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 85f83352859..318147ef518 100644 --- a/routes/web.php +++ b/routes/web.php @@ -361,6 +361,7 @@ Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public'); // Metadata routes +Route::view('/help/tinymce', 'help.tinymce'); Route::view('/help/wysiwyg', 'help.wysiwyg'); Route::fallback([MetaController::class, 'notFound'])->name('fallback'); diff --git a/tests/Meta/HelpTest.php b/tests/Meta/HelpTest.php index e1de96bc8df..acce65394a3 100644 --- a/tests/Meta/HelpTest.php +++ b/tests/Meta/HelpTest.php @@ -6,9 +6,9 @@ class HelpTest extends TestCase { - public function test_wysiwyg_help_shows_tiny_and_tiny_license_link() + public function test_tinymce_help_shows_tiny_and_tiny_license_link() { - $resp = $this->get('/help/wysiwyg'); + $resp = $this->get('/help/tinymce'); $resp->assertOk(); $this->withHtml($resp)->assertElementExists('a[href="https://www.tiny.cloud/"]'); $this->withHtml($resp)->assertElementExists('a[href="' . url('/libs/tinymce/license.txt') . '"]'); @@ -22,4 +22,12 @@ public function test_tiny_license_exists_where_expected() $contents = file_get_contents($expectedPath); $this->assertStringContainsString('MIT License', $contents); } + + public function test_wysiwyg_help_shows_lexical_and_licenses_link() + { + $resp = $this->get('/help/wysiwyg'); + $resp->assertOk(); + $this->withHtml($resp)->assertElementExists('a[href="https://lexical.dev/"]'); + $this->withHtml($resp)->assertElementExists('a[href="' . url('/licenses') . '"]'); + } }