Merge pull request #5365 from BookStackApp/lexical_fixes
Range of fixes/updates for the new Lexical based editor
This commit is contained in:
		
						commit
						1f88bc2a59
					
				| 
						 | 
					@ -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';
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -185,6 +185,7 @@ const config: Config = {
 | 
				
			||||||
  // A map from regular expressions to paths to transformers
 | 
					  // A map from regular expressions to paths to transformers
 | 
				
			||||||
  transform: {
 | 
					  transform: {
 | 
				
			||||||
    "^.+.tsx?$": ["ts-jest",{}],
 | 
					    "^.+.tsx?$": ["ts-jest",{}],
 | 
				
			||||||
 | 
					    "^.+.svg$": ["<rootDir>/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
 | 
					  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -163,6 +163,8 @@ return [
 | 
				
			||||||
    'about' => 'About the editor',
 | 
					    'about' => 'About the editor',
 | 
				
			||||||
    'about_title' => 'About the WYSIWYG Editor',
 | 
					    'about_title' => 'About the WYSIWYG Editor',
 | 
				
			||||||
    'editor_license' => 'Editor License & Copyright',
 | 
					    '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' => '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.',
 | 
					    'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.',
 | 
				
			||||||
    'save_continue' => 'Save Page & Continue',
 | 
					    'save_continue' => 'Save Page & Continue',
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
		 Before Width: | Height: | Size: 653 B After Width: | Height: | Size: 653 B  | 
| 
						 | 
					@ -0,0 +1 @@
 | 
				
			||||||
 | 
					<svg viewbox="0 0 24 24"><path d="M8.12 19.3c.39.39 1.02.39 1.41 0L12 16.83l2.47 2.47c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41l-3.17-3.17c-.39-.39-1.02-.39-1.41 0l-3.17 3.17c-.4.38-.4 1.02-.01 1.41zm7.76-14.6c-.39-.39-1.02-.39-1.41 0L12 7.17 9.53 4.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.03 0 1.42l3.17 3.17c.39.39 1.02.39 1.41 0l3.17-3.17c.4-.39.4-1.03.01-1.42z"/></svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 377 B  | 
| 
						 | 
					@ -4,7 +4,7 @@
 | 
				
			||||||
function register(editor) {
 | 
					function register(editor) {
 | 
				
			||||||
    const aboutDialog = {
 | 
					    const aboutDialog = {
 | 
				
			||||||
        title: 'About the WYSIWYG Editor',
 | 
					        title: 'About the WYSIWYG Editor',
 | 
				
			||||||
        url: window.baseUrl('/help/wysiwyg'),
 | 
					        url: window.baseUrl('/help/tinymce'),
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    editor.ui.registry.addButton('about', {
 | 
					    editor.ui.registry.addButton('about', {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -15,6 +15,7 @@ import {el} from "./utils/dom";
 | 
				
			||||||
import {registerShortcuts} from "./services/shortcuts";
 | 
					import {registerShortcuts} from "./services/shortcuts";
 | 
				
			||||||
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
 | 
					import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
 | 
				
			||||||
import {registerKeyboardHandling} from "./services/keyboard-handling";
 | 
					import {registerKeyboardHandling} from "./services/keyboard-handling";
 | 
				
			||||||
 | 
					import {registerAutoLinks} from "./services/auto-links";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
 | 
					export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
 | 
				
			||||||
    const config: CreateEditorArgs = {
 | 
					    const config: CreateEditorArgs = {
 | 
				
			||||||
| 
						 | 
					@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
 | 
				
			||||||
        registerTaskListHandler(editor, editArea),
 | 
					        registerTaskListHandler(editor, editArea),
 | 
				
			||||||
        registerDropPasteHandling(context),
 | 
					        registerDropPasteHandling(context),
 | 
				
			||||||
        registerNodeResizer(context),
 | 
					        registerNodeResizer(context),
 | 
				
			||||||
 | 
					        registerAutoLinks(editor),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    listenToCommonEvents(editor);
 | 
					    listenToCommonEvents(editor);
 | 
				
			||||||
| 
						 | 
					@ -73,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
 | 
				
			||||||
    const debugView = document.getElementById('lexical-debug');
 | 
					    const debugView = document.getElementById('lexical-debug');
 | 
				
			||||||
    if (debugView) {
 | 
					    if (debugView) {
 | 
				
			||||||
        debugView.hidden = true;
 | 
					        debugView.hidden = true;
 | 
				
			||||||
    }
 | 
					        editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
 | 
				
			||||||
 | 
					            // Debug logic
 | 
				
			||||||
    let changeFromLoading = true;
 | 
					            // console.log('editorState', editorState.toJSON());
 | 
				
			||||||
    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) {
 | 
					 | 
				
			||||||
            debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
 | 
					            debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
 | 
				
			||||||
        }
 | 
					        });
 | 
				
			||||||
    });
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // @ts-ignore
 | 
					    // @ts-ignore
 | 
				
			||||||
    window.debugEditorState = () => {
 | 
					    window.debugEditorState = () => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1188,6 +1188,14 @@ export class LexicalEditor {
 | 
				
			||||||
    updateEditor(this, updateFn, options);
 | 
					    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
 | 
					   * Focuses the editor
 | 
				
			||||||
   * @param callbackFn - A function to run after the editor is focused.
 | 
					   * @param callbackFn - A function to run after the editor is focused.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -142,10 +142,15 @@ export type DOMConversionMap<T extends HTMLElement = HTMLElement> = Record<
 | 
				
			||||||
>;
 | 
					>;
 | 
				
			||||||
type NodeName = string;
 | 
					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 = {
 | 
					export type DOMConversionOutput = {
 | 
				
			||||||
  after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
 | 
					  after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
 | 
				
			||||||
  forChild?: DOMChildConversion;
 | 
					  forChild?: DOMChildConversion;
 | 
				
			||||||
  node: null | LexicalNode | Array<LexicalNode>;
 | 
					  node: null | LexicalNode | Array<LexicalNode> | 'ignore';
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type DOMExportOutputMap = Map<
 | 
					export type DOMExportOutputMap = Map<
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list';
 | 
				
			||||||
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
 | 
					import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
 | 
					  $getSelection,
 | 
				
			||||||
  $isRangeSelection,
 | 
					  $isRangeSelection,
 | 
				
			||||||
  createEditor,
 | 
					  createEditor,
 | 
				
			||||||
  DecoratorNode,
 | 
					  DecoratorNode,
 | 
				
			||||||
| 
						 | 
					@ -29,14 +30,14 @@ import {
 | 
				
			||||||
  TextNode,
 | 
					  TextNode,
 | 
				
			||||||
} from 'lexical';
 | 
					} from 'lexical';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import {
 | 
					import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor';
 | 
				
			||||||
  CreateEditorArgs,
 | 
					 | 
				
			||||||
  HTMLConfig,
 | 
					 | 
				
			||||||
  LexicalNodeReplacement,
 | 
					 | 
				
			||||||
} from '../../LexicalEditor';
 | 
					 | 
				
			||||||
import {resetRandomKey} from '../../LexicalUtils';
 | 
					import {resetRandomKey} from '../../LexicalUtils';
 | 
				
			||||||
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
 | 
					import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
 | 
				
			||||||
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
 | 
					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 = {
 | 
					type TestEnv = {
 | 
				
			||||||
| 
						 | 
					@ -420,6 +421,7 @@ const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeR
 | 
				
			||||||
  TableRowNode,
 | 
					  TableRowNode,
 | 
				
			||||||
  AutoLinkNode,
 | 
					  AutoLinkNode,
 | 
				
			||||||
  LinkNode,
 | 
					  LinkNode,
 | 
				
			||||||
 | 
					  DetailsNode,
 | 
				
			||||||
  TestElementNode,
 | 
					  TestElementNode,
 | 
				
			||||||
  TestSegmentedNode,
 | 
					  TestSegmentedNode,
 | 
				
			||||||
  TestExcludeFromCopyElementNode,
 | 
					  TestExcludeFromCopyElementNode,
 | 
				
			||||||
| 
						 | 
					@ -451,6 +453,7 @@ export function createTestEditor(
 | 
				
			||||||
    ...config,
 | 
					    ...config,
 | 
				
			||||||
    nodes: DEFAULT_NODES.concat(customNodes),
 | 
					    nodes: DEFAULT_NODES.concat(customNodes),
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return editor;
 | 
					  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 {
 | 
					export function $assertRangeSelection(selection: unknown): RangeSelection {
 | 
				
			||||||
  if (!$isRangeSelection(selection)) {
 | 
					  if (!$isRangeSelection(selection)) {
 | 
				
			||||||
    throw new Error(`Expected RangeSelection, got ${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));
 | 
					  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 {
 | 
					function formatHtml(s: string): string {
 | 
				
			||||||
  return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
 | 
					  return s.replace(/>\s+</g, '><').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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -62,7 +62,6 @@ describe('LexicalHeadlessEditor', () => {
 | 
				
			||||||
  it('should be headless environment', async () => {
 | 
					  it('should be headless environment', async () => {
 | 
				
			||||||
    expect(typeof window === 'undefined').toBe(true);
 | 
					    expect(typeof window === 'undefined').toBe(true);
 | 
				
			||||||
    expect(typeof document === 'undefined').toBe(true);
 | 
					    expect(typeof document === 'undefined').toBe(true);
 | 
				
			||||||
    expect(typeof navigator === 'undefined').toBe(true);
 | 
					 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('can update editor', async () => {
 | 
					  it('can update editor', async () => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -217,6 +217,11 @@ function $createNodesFromDOM(
 | 
				
			||||||
  if (transformOutput !== null) {
 | 
					  if (transformOutput !== null) {
 | 
				
			||||||
    postTransform = transformOutput.after;
 | 
					    postTransform = transformOutput.after;
 | 
				
			||||||
    const transformNodes = transformOutput.node;
 | 
					    const transformNodes = transformOutput.node;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (transformNodes === 'ignore') {
 | 
				
			||||||
 | 
					      return lexicalNodes;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    currentLexicalNode = Array.isArray(transformNodes)
 | 
					    currentLexicalNode = Array.isArray(transformNodes)
 | 
				
			||||||
      ? transformNodes[transformNodes.length - 1]
 | 
					      ? transformNodes[transformNodes.length - 1]
 | 
				
			||||||
      : transformNodes;
 | 
					      : transformNodes;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -271,11 +271,18 @@ export class ListItemNode extends ElementNode {
 | 
				
			||||||
  insertNewAfter(
 | 
					  insertNewAfter(
 | 
				
			||||||
    _: RangeSelection,
 | 
					    _: RangeSelection,
 | 
				
			||||||
    restoreSelection = true,
 | 
					    restoreSelection = true,
 | 
				
			||||||
  ): ListItemNode | ParagraphNode {
 | 
					  ): ListItemNode | ParagraphNode | null {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (this.getTextContent().trim() === '' && this.isLastChild()) {
 | 
					    if (this.getTextContent().trim() === '' && this.isLastChild()) {
 | 
				
			||||||
      const list = this.getParentOrThrow<ListNode>();
 | 
					      const list = this.getParentOrThrow<ListNode>();
 | 
				
			||||||
      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();
 | 
					        const paragraph = $createParagraphNode();
 | 
				
			||||||
        list.insertAfter(paragraph, restoreSelection);
 | 
					        list.insertAfter(paragraph, restoreSelection);
 | 
				
			||||||
        this.remove();
 | 
					        this.remove();
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| 
						 | 
					@ -5,18 +5,20 @@ import {
 | 
				
			||||||
    LexicalEditor,
 | 
					    LexicalEditor,
 | 
				
			||||||
    LexicalNode,
 | 
					    LexicalNode,
 | 
				
			||||||
    SerializedElementNode, Spread,
 | 
					    SerializedElementNode, Spread,
 | 
				
			||||||
    EditorConfig,
 | 
					    EditorConfig, DOMExportOutput,
 | 
				
			||||||
} from 'lexical';
 | 
					} from 'lexical';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import {el} from "../../utils/dom";
 | 
					 | 
				
			||||||
import {extractDirectionFromElement} from "lexical/nodes/common";
 | 
					import {extractDirectionFromElement} from "lexical/nodes/common";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type SerializedDetailsNode = Spread<{
 | 
					export type SerializedDetailsNode = Spread<{
 | 
				
			||||||
    id: string;
 | 
					    id: string;
 | 
				
			||||||
 | 
					    summary: string;
 | 
				
			||||||
}, SerializedElementNode>
 | 
					}, SerializedElementNode>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class DetailsNode extends ElementNode {
 | 
					export class DetailsNode extends ElementNode {
 | 
				
			||||||
    __id: string = '';
 | 
					    __id: string = '';
 | 
				
			||||||
 | 
					    __summary: string = '';
 | 
				
			||||||
 | 
					    __open: boolean = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    static getType() {
 | 
					    static getType() {
 | 
				
			||||||
        return 'details';
 | 
					        return 'details';
 | 
				
			||||||
| 
						 | 
					@ -32,10 +34,32 @@ export class DetailsNode extends ElementNode {
 | 
				
			||||||
        return self.__id;
 | 
					        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 {
 | 
					    static clone(node: DetailsNode): DetailsNode {
 | 
				
			||||||
        const newNode =  new DetailsNode(node.__key);
 | 
					        const newNode =  new DetailsNode(node.__key);
 | 
				
			||||||
        newNode.__id = node.__id;
 | 
					        newNode.__id = node.__id;
 | 
				
			||||||
        newNode.__dir = node.__dir;
 | 
					        newNode.__dir = node.__dir;
 | 
				
			||||||
 | 
					        newNode.__summary = node.__summary;
 | 
				
			||||||
 | 
					        newNode.__open = node.__open;
 | 
				
			||||||
        return newNode;
 | 
					        return newNode;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -49,12 +73,34 @@ export class DetailsNode extends ElementNode {
 | 
				
			||||||
            el.setAttribute('dir', this.__dir);
 | 
					            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;
 | 
					        return el;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    updateDOM(prevNode: DetailsNode, dom: HTMLElement) {
 | 
					    updateDOM(prevNode: DetailsNode, dom: HTMLElement) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (prevNode.__open !== this.__open) {
 | 
				
			||||||
 | 
					            dom.toggleAttribute('open', this.__open);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return prevNode.__id !== this.__id
 | 
					        return prevNode.__id !== this.__id
 | 
				
			||||||
        || prevNode.__dir !== this.__dir;
 | 
					        || prevNode.__dir !== this.__dir
 | 
				
			||||||
 | 
					        || prevNode.__summary !== this.__summary;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    static importDOM(): DOMConversionMap|null {
 | 
					    static importDOM(): DOMConversionMap|null {
 | 
				
			||||||
| 
						 | 
					@ -71,20 +117,44 @@ export class DetailsNode extends ElementNode {
 | 
				
			||||||
                            node.setDirection(extractDirectionFromElement(element));
 | 
					                            node.setDirection(extractDirectionFromElement(element));
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        const summaryElem = Array.from(element.children).find(e => e.nodeName === 'SUMMARY');
 | 
				
			||||||
 | 
					                        node.setSummary(summaryElem?.textContent || '');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        return {node};
 | 
					                        return {node};
 | 
				
			||||||
                    },
 | 
					                    },
 | 
				
			||||||
                    priority: 3,
 | 
					                    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 {
 | 
					    exportJSON(): SerializedDetailsNode {
 | 
				
			||||||
        return {
 | 
					        return {
 | 
				
			||||||
            ...super.exportJSON(),
 | 
					            ...super.exportJSON(),
 | 
				
			||||||
            type: 'details',
 | 
					            type: 'details',
 | 
				
			||||||
            version: 1,
 | 
					            version: 1,
 | 
				
			||||||
            id: this.__id,
 | 
					            id: this.__id,
 | 
				
			||||||
 | 
					            summary: this.__summary,
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -104,58 +174,3 @@ export function $createDetailsNode() {
 | 
				
			||||||
export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode {
 | 
					export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode {
 | 
				
			||||||
    return node instanceof 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;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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(`<details><summary contenteditable="false"></summary></details>`);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        test('exportDOM()', () => {
 | 
				
			||||||
 | 
					            const {editor} = testEnv;
 | 
				
			||||||
 | 
					            let html!: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            editor.updateAndCommit(() => {
 | 
				
			||||||
 | 
					                const details = $createDetailsNode();
 | 
				
			||||||
 | 
					                html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            expect(html).toBe(`<details><summary></summary></details>`);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
| 
						 | 
					@ -8,7 +8,7 @@ import {
 | 
				
			||||||
} from "lexical";
 | 
					} from "lexical";
 | 
				
			||||||
import {LinkNode} from "@lexical/link";
 | 
					import {LinkNode} from "@lexical/link";
 | 
				
			||||||
import {ImageNode} from "@lexical/rich-text/LexicalImageNode";
 | 
					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 {ListItemNode, ListNode} from "@lexical/list";
 | 
				
			||||||
import {TableCellNode, TableNode, TableRowNode} from "@lexical/table";
 | 
					import {TableCellNode, TableNode, TableRowNode} from "@lexical/table";
 | 
				
			||||||
import {HorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode";
 | 
					import {HorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode";
 | 
				
			||||||
| 
						 | 
					@ -34,7 +34,7 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
 | 
				
			||||||
        TableCellNode,
 | 
					        TableCellNode,
 | 
				
			||||||
        ImageNode, // TODO - Alignment
 | 
					        ImageNode, // TODO - Alignment
 | 
				
			||||||
        HorizontalRuleNode,
 | 
					        HorizontalRuleNode,
 | 
				
			||||||
        DetailsNode, SummaryNode,
 | 
					        DetailsNode,
 | 
				
			||||||
        CodeBlockNode,
 | 
					        CodeBlockNode,
 | 
				
			||||||
        DiagramNode,
 | 
					        DiagramNode,
 | 
				
			||||||
        MediaNode, // TODO - Alignment
 | 
					        MediaNode, // TODO - Alignment
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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');
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
| 
						 | 
					@ -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());
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
| 
						 | 
					@ -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();
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
import {LexicalEditor} from "lexical";
 | 
					import {$getSelection, LexicalEditor} from "lexical";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    appendHtmlToEditor,
 | 
					    appendHtmlToEditor,
 | 
				
			||||||
    focusEditor,
 | 
					    focusEditor,
 | 
				
			||||||
| 
						 | 
					@ -40,4 +40,16 @@ export function listen(editor: LexicalEditor): void {
 | 
				
			||||||
    window.$events.listen<EditorEventContent>('editor::focus', () => {
 | 
					    window.$events.listen<EditorEventContent>('editor::focus', () => {
 | 
				
			||||||
        focusEditor(editor);
 | 
					        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', '');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,7 +3,7 @@ import {
 | 
				
			||||||
    $createParagraphNode,
 | 
					    $createParagraphNode,
 | 
				
			||||||
    $getSelection,
 | 
					    $getSelection,
 | 
				
			||||||
    $isDecoratorNode,
 | 
					    $isDecoratorNode,
 | 
				
			||||||
    COMMAND_PRIORITY_LOW,
 | 
					    COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND,
 | 
				
			||||||
    KEY_BACKSPACE_COMMAND,
 | 
					    KEY_BACKSPACE_COMMAND,
 | 
				
			||||||
    KEY_DELETE_COMMAND,
 | 
					    KEY_DELETE_COMMAND,
 | 
				
			||||||
    KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
 | 
					    KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
 | 
				
			||||||
| 
						 | 
					@ -13,9 +13,10 @@ import {
 | 
				
			||||||
import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
 | 
					import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
 | 
				
			||||||
import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
 | 
					import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
 | 
				
			||||||
import {getLastSelection} from "../utils/selection";
 | 
					import {getLastSelection} from "../utils/selection";
 | 
				
			||||||
import {$getNearestNodeBlockParent} from "../utils/nodes";
 | 
					import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes";
 | 
				
			||||||
import {$setInsetForSelection} from "../utils/lists";
 | 
					import {$setInsetForSelection} from "../utils/lists";
 | 
				
			||||||
import {$isListItemNode} from "@lexical/list";
 | 
					import {$isListItemNode} from "@lexical/list";
 | 
				
			||||||
 | 
					import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
 | 
					function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
 | 
				
			||||||
    if (nodes.length === 1) {
 | 
					    if (nodes.length === 1) {
 | 
				
			||||||
| 
						 | 
					@ -28,6 +29,10 @@ function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
 | 
				
			||||||
    return false;
 | 
					    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) {
 | 
					function deleteSingleSelectedNode(editor: LexicalEditor) {
 | 
				
			||||||
    const selectionNodes = getLastSelection(editor)?.getNodes() || [];
 | 
					    const selectionNodes = getLastSelection(editor)?.getNodes() || [];
 | 
				
			||||||
    if (isSingleSelectedNode(selectionNodes)) {
 | 
					    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 {
 | 
					function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
 | 
				
			||||||
    const selectionNodes = getLastSelection(editor)?.getNodes() || [];
 | 
					    const selectionNodes = getLastSelection(editor)?.getNodes() || [];
 | 
				
			||||||
    if (isSingleSelectedNode(selectionNodes)) {
 | 
					    if (isSingleSelectedNode(selectionNodes)) {
 | 
				
			||||||
| 
						 | 
					@ -58,11 +67,108 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve
 | 
				
			||||||
    return false;
 | 
					    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 {
 | 
					function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
 | 
				
			||||||
    const change = event?.shiftKey ? -40 : 40;
 | 
					    const change = event?.shiftKey ? -40 : 40;
 | 
				
			||||||
    const selection = $getSelection();
 | 
					    const selection = $getSelection();
 | 
				
			||||||
    const nodes = selection?.getNodes() || [];
 | 
					    const nodes = selection?.getNodes() || [];
 | 
				
			||||||
    if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) {
 | 
					    if (nodes.length > 1 || $isSingleListItem(nodes)) {
 | 
				
			||||||
        editor.update(() => {
 | 
					        editor.update(() => {
 | 
				
			||||||
            $setInsetForSelection(editor, change);
 | 
					            $setInsetForSelection(editor, change);
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
| 
						 | 
					@ -85,17 +191,23 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
 | 
				
			||||||
    }, COMMAND_PRIORITY_LOW);
 | 
					    }, COMMAND_PRIORITY_LOW);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
 | 
					    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);
 | 
					    }, COMMAND_PRIORITY_LOW);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
 | 
					    const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
 | 
				
			||||||
        return handleInsetOnTab(context.editor, event);
 | 
					        return handleInsetOnTab(context.editor, event);
 | 
				
			||||||
    }, COMMAND_PRIORITY_LOW);
 | 
					    }, COMMAND_PRIORITY_LOW);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => {
 | 
				
			||||||
 | 
					        return insertAfterDetails(context.editor, event);
 | 
				
			||||||
 | 
					    }, COMMAND_PRIORITY_LOW);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return () => {
 | 
					    return () => {
 | 
				
			||||||
        unregisterBackspace();
 | 
					        unregisterBackspace();
 | 
				
			||||||
        unregisterDelete();
 | 
					        unregisterDelete();
 | 
				
			||||||
        unregisterEnter();
 | 
					        unregisterEnter();
 | 
				
			||||||
        unregisterTab();
 | 
					        unregisterTab();
 | 
				
			||||||
 | 
					        unregisterDown();
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -11,8 +11,9 @@ import {
 | 
				
			||||||
} from "lexical";
 | 
					} from "lexical";
 | 
				
			||||||
import redoIcon from "@icons/editor/redo.svg";
 | 
					import redoIcon from "@icons/editor/redo.svg";
 | 
				
			||||||
import sourceIcon from "@icons/editor/source-view.svg";
 | 
					import sourceIcon from "@icons/editor/source-view.svg";
 | 
				
			||||||
import {getEditorContentAsHtml} from "../../../utils/actions";
 | 
					 | 
				
			||||||
import fullscreenIcon from "@icons/editor/fullscreen.svg";
 | 
					import fullscreenIcon from "@icons/editor/fullscreen.svg";
 | 
				
			||||||
 | 
					import aboutIcon from "@icons/editor/about.svg";
 | 
				
			||||||
 | 
					import {getEditorContentAsHtml} from "../../../utils/actions";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const undo: EditorButtonDefinition = {
 | 
					export const undo: EditorButtonDefinition = {
 | 
				
			||||||
    label: 'Undo',
 | 
					    label: 'Undo',
 | 
				
			||||||
| 
						 | 
					@ -81,3 +82,15 @@ export const fullscreen: EditorButtonDefinition = {
 | 
				
			||||||
        return context.containerDOM.classList.contains('fullscreen');
 | 
					        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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -19,6 +19,9 @@ import editIcon from "@icons/edit.svg";
 | 
				
			||||||
import diagramIcon from "@icons/editor/diagram.svg";
 | 
					import diagramIcon from "@icons/editor/diagram.svg";
 | 
				
			||||||
import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
 | 
					import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
 | 
				
			||||||
import detailsIcon from "@icons/editor/details.svg";
 | 
					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 mediaIcon from "@icons/editor/media.svg";
 | 
				
			||||||
import {$createDetailsNode, $isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
					import {$createDetailsNode, $isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
				
			||||||
import {$isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode";
 | 
					import {$isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode";
 | 
				
			||||||
| 
						 | 
					@ -29,7 +32,7 @@ import {
 | 
				
			||||||
} from "../../../utils/selection";
 | 
					} from "../../../utils/selection";
 | 
				
			||||||
import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams";
 | 
					import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams";
 | 
				
			||||||
import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
 | 
					import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
 | 
				
			||||||
import {$showImageForm, $showLinkForm} from "../forms/objects";
 | 
					import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects";
 | 
				
			||||||
import {formatCodeBlock} from "../../../utils/formats";
 | 
					import {formatCodeBlock} from "../../../utils/formats";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const link: EditorButtonDefinition = {
 | 
					export const link: EditorButtonDefinition = {
 | 
				
			||||||
| 
						 | 
					@ -217,3 +220,57 @@ export const details: EditorButtonDefinition = {
 | 
				
			||||||
        return $selectionContainsNodeType(selection, $isDetailsNode);
 | 
					        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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,7 @@
 | 
				
			||||||
import {EditorFormDefinition} from "../../framework/forms";
 | 
					import {EditorFormDefinition} from "../../framework/forms";
 | 
				
			||||||
import {EditorUiContext} from "../../framework/core";
 | 
					import {EditorUiContext, EditorUiElement} from "../../framework/core";
 | 
				
			||||||
import {setEditorContentFromHtml} from "../../../utils/actions";
 | 
					import {setEditorContentFromHtml} from "../../../utils/actions";
 | 
				
			||||||
 | 
					import {ExternalContent} from "../../framework/blocks/external-content";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const source: EditorFormDefinition = {
 | 
					export const source: EditorFormDefinition = {
 | 
				
			||||||
    submitText: 'Save',
 | 
					    submitText: 'Save',
 | 
				
			||||||
| 
						 | 
					@ -16,3 +17,17 @@ export const source: EditorFormDefinition = {
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const about: EditorFormDefinition = {
 | 
				
			||||||
 | 
					    submitText: 'Close',
 | 
				
			||||||
 | 
					    async action() {
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    fields: [
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            build(): EditorUiElement {
 | 
				
			||||||
 | 
					                return new ExternalContent('/help/wysiwyg');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -19,6 +19,7 @@ import searchIcon from "@icons/search.svg";
 | 
				
			||||||
import {showLinkSelector} from "../../../utils/links";
 | 
					import {showLinkSelector} from "../../../utils/links";
 | 
				
			||||||
import {LinkField} from "../../framework/blocks/link-field";
 | 
					import {LinkField} from "../../framework/blocks/link-field";
 | 
				
			||||||
import {insertOrUpdateLink} from "../../../utils/formats";
 | 
					import {insertOrUpdateLink} from "../../../utils/formats";
 | 
				
			||||||
 | 
					import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function $showImageForm(image: ImageNode, context: EditorUiContext) {
 | 
					export function $showImageForm(image: ImageNode, context: EditorUiContext) {
 | 
				
			||||||
    const imageModal: EditorFormModal = context.manager.createModal('image');
 | 
					    const imageModal: EditorFormModal = context.manager.createModal('image');
 | 
				
			||||||
| 
						 | 
					@ -263,3 +264,36 @@ 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',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
import {EditorFormModalDefinition} from "../framework/modals";
 | 
					import {EditorFormModalDefinition} from "../framework/modals";
 | 
				
			||||||
import {image, link, media} from "./forms/objects";
 | 
					import {details, image, link, media} from "./forms/objects";
 | 
				
			||||||
import {source} from "./forms/controls";
 | 
					import {about, source} from "./forms/controls";
 | 
				
			||||||
import {cellProperties, rowProperties, tableProperties} from "./forms/tables";
 | 
					import {cellProperties, rowProperties, tableProperties} from "./forms/tables";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const modals: Record<string, EditorFormModalDefinition> = {
 | 
					export const modals: Record<string, EditorFormModalDefinition> = {
 | 
				
			||||||
| 
						 | 
					@ -32,4 +32,12 @@ export const modals: Record<string, EditorFormModalDefinition> = {
 | 
				
			||||||
        title: 'Table Properties',
 | 
					        title: 'Table Properties',
 | 
				
			||||||
        form: tableProperties,
 | 
					        form: tableProperties,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    details: {
 | 
				
			||||||
 | 
					        title: 'Edit collapsible block',
 | 
				
			||||||
 | 
					        form: details,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    about: {
 | 
				
			||||||
 | 
					        title: 'About the WYSIWYG Editor',
 | 
				
			||||||
 | 
					        form: about,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -1,12 +1,12 @@
 | 
				
			||||||
import {EditorButton} from "./framework/buttons";
 | 
					import {EditorButton} from "../framework/buttons";
 | 
				
			||||||
import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core";
 | 
					import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "../framework/core";
 | 
				
			||||||
import {EditorFormatMenu} from "./framework/blocks/format-menu";
 | 
					import {EditorFormatMenu} from "../framework/blocks/format-menu";
 | 
				
			||||||
import {FormatPreviewButton} from "./framework/blocks/format-preview-button";
 | 
					import {FormatPreviewButton} from "../framework/blocks/format-preview-button";
 | 
				
			||||||
import {EditorDropdownButton} from "./framework/blocks/dropdown-button";
 | 
					import {EditorDropdownButton} from "../framework/blocks/dropdown-button";
 | 
				
			||||||
import {EditorColorPicker} from "./framework/blocks/color-picker";
 | 
					import {EditorColorPicker} from "../framework/blocks/color-picker";
 | 
				
			||||||
import {EditorTableCreator} from "./framework/blocks/table-creator";
 | 
					import {EditorTableCreator} from "../framework/blocks/table-creator";
 | 
				
			||||||
import {EditorColorButton} from "./framework/blocks/color-button";
 | 
					import {EditorColorButton} from "../framework/blocks/color-button";
 | 
				
			||||||
import {EditorOverflowContainer} from "./framework/blocks/overflow-container";
 | 
					import {EditorOverflowContainer} from "../framework/blocks/overflow-container";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    cellProperties, clearTableFormatting,
 | 
					    cellProperties, clearTableFormatting,
 | 
				
			||||||
    copyColumn,
 | 
					    copyColumn,
 | 
				
			||||||
| 
						 | 
					@ -29,8 +29,8 @@ import {
 | 
				
			||||||
    rowProperties,
 | 
					    rowProperties,
 | 
				
			||||||
    splitCell,
 | 
					    splitCell,
 | 
				
			||||||
    table, tableProperties
 | 
					    table, tableProperties
 | 
				
			||||||
} from "./defaults/buttons/tables";
 | 
					} from "./buttons/tables";
 | 
				
			||||||
import {fullscreen, redo, source, undo} from "./defaults/buttons/controls";
 | 
					import {about, fullscreen, redo, source, undo} from "./buttons/controls";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    blockquote, dangerCallout,
 | 
					    blockquote, dangerCallout,
 | 
				
			||||||
    h2,
 | 
					    h2,
 | 
				
			||||||
| 
						 | 
					@ -41,7 +41,7 @@ import {
 | 
				
			||||||
    paragraph,
 | 
					    paragraph,
 | 
				
			||||||
    successCallout,
 | 
					    successCallout,
 | 
				
			||||||
    warningCallout
 | 
					    warningCallout
 | 
				
			||||||
} from "./defaults/buttons/block-formats";
 | 
					} from "./buttons/block-formats";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    bold, clearFormating, code,
 | 
					    bold, clearFormating, code,
 | 
				
			||||||
    highlightColor,
 | 
					    highlightColor,
 | 
				
			||||||
| 
						 | 
					@ -50,7 +50,7 @@ import {
 | 
				
			||||||
    superscript,
 | 
					    superscript,
 | 
				
			||||||
    textColor,
 | 
					    textColor,
 | 
				
			||||||
    underline
 | 
					    underline
 | 
				
			||||||
} from "./defaults/buttons/inline-formats";
 | 
					} from "./buttons/inline-formats";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    alignCenter,
 | 
					    alignCenter,
 | 
				
			||||||
    alignJustify,
 | 
					    alignJustify,
 | 
				
			||||||
| 
						 | 
					@ -58,27 +58,27 @@ import {
 | 
				
			||||||
    alignRight,
 | 
					    alignRight,
 | 
				
			||||||
    directionLTR,
 | 
					    directionLTR,
 | 
				
			||||||
    directionRTL
 | 
					    directionRTL
 | 
				
			||||||
} from "./defaults/buttons/alignments";
 | 
					} from "./buttons/alignments";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    bulletList,
 | 
					    bulletList,
 | 
				
			||||||
    indentDecrease,
 | 
					    indentDecrease,
 | 
				
			||||||
    indentIncrease,
 | 
					    indentIncrease,
 | 
				
			||||||
    numberList,
 | 
					    numberList,
 | 
				
			||||||
    taskList
 | 
					    taskList
 | 
				
			||||||
} from "./defaults/buttons/lists";
 | 
					} from "./buttons/lists";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    codeBlock,
 | 
					    codeBlock,
 | 
				
			||||||
    details,
 | 
					    details, detailsEditLabel, detailsToggle, detailsUnwrap,
 | 
				
			||||||
    diagram, diagramManager,
 | 
					    diagram, diagramManager,
 | 
				
			||||||
    editCodeBlock,
 | 
					    editCodeBlock,
 | 
				
			||||||
    horizontalRule,
 | 
					    horizontalRule,
 | 
				
			||||||
    image,
 | 
					    image,
 | 
				
			||||||
    link, media,
 | 
					    link, media,
 | 
				
			||||||
    unlink
 | 
					    unlink
 | 
				
			||||||
} from "./defaults/buttons/objects";
 | 
					} from "./buttons/objects";
 | 
				
			||||||
import {el} from "../utils/dom";
 | 
					import {el} from "../../utils/dom";
 | 
				
			||||||
import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu";
 | 
					import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu";
 | 
				
			||||||
import {EditorSeparator} from "./framework/blocks/separator";
 | 
					import {EditorSeparator} from "../framework/blocks/separator";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement {
 | 
					export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -149,8 +149,8 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
 | 
				
			||||||
        new EditorOverflowContainer(4, [
 | 
					        new EditorOverflowContainer(4, [
 | 
				
			||||||
            new EditorButton(link),
 | 
					            new EditorButton(link),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            new EditorDropdownButton({button: table, direction: 'vertical'}, [
 | 
					            new EditorDropdownButton({button: table, direction: 'vertical', showAside: false}, [
 | 
				
			||||||
                new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true}, [
 | 
					                new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true, showAside: true}, [
 | 
				
			||||||
                    new EditorTableCreator(),
 | 
					                    new EditorTableCreator(),
 | 
				
			||||||
                ]),
 | 
					                ]),
 | 
				
			||||||
                new EditorSeparator(),
 | 
					                new EditorSeparator(),
 | 
				
			||||||
| 
						 | 
					@ -201,6 +201,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
 | 
				
			||||||
        // Meta elements
 | 
					        // Meta elements
 | 
				
			||||||
        new EditorOverflowContainer(3, [
 | 
					        new EditorOverflowContainer(3, [
 | 
				
			||||||
            new EditorButton(source),
 | 
					            new EditorButton(source),
 | 
				
			||||||
 | 
					            new EditorButton(about),
 | 
				
			||||||
            new EditorButton(fullscreen),
 | 
					            new EditorButton(fullscreen),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Test
 | 
					            // Test
 | 
				
			||||||
| 
						 | 
					@ -254,3 +255,11 @@ export function getTableToolbarContent(): EditorUiElement[] {
 | 
				
			||||||
        ]),
 | 
					        ]),
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function getDetailsToolbarContent(): EditorUiElement[] {
 | 
				
			||||||
 | 
					    return [
 | 
				
			||||||
 | 
					        new EditorButton(detailsEditLabel),
 | 
				
			||||||
 | 
					        new EditorButton(detailsToggle),
 | 
				
			||||||
 | 
					        new EditorButton(detailsUnwrap),
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -16,6 +16,7 @@ export class EditorButtonWithMenu extends EditorContainerUiElement {
 | 
				
			||||||
            button: {label: 'Menu', icon: caretDownIcon},
 | 
					            button: {label: 'Menu', icon: caretDownIcon},
 | 
				
			||||||
            showOnHover: false,
 | 
					            showOnHover: false,
 | 
				
			||||||
            direction: 'vertical',
 | 
					            direction: 'vertical',
 | 
				
			||||||
 | 
					            showAside: false,
 | 
				
			||||||
        }, menuItems);
 | 
					        }, menuItems);
 | 
				
			||||||
        this.addChildren(this.dropdownButton);
 | 
					        this.addChildren(this.dropdownButton);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,12 +7,14 @@ import {EditorMenuButton} from "./menu-button";
 | 
				
			||||||
export type EditorDropdownButtonOptions = {
 | 
					export type EditorDropdownButtonOptions = {
 | 
				
			||||||
    showOnHover?: boolean;
 | 
					    showOnHover?: boolean;
 | 
				
			||||||
    direction?: 'vertical'|'horizontal';
 | 
					    direction?: 'vertical'|'horizontal';
 | 
				
			||||||
 | 
					    showAside?: boolean;
 | 
				
			||||||
    button: EditorBasicButtonDefinition|EditorButton;
 | 
					    button: EditorBasicButtonDefinition|EditorButton;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const defaultOptions: EditorDropdownButtonOptions = {
 | 
					const defaultOptions: EditorDropdownButtonOptions = {
 | 
				
			||||||
    showOnHover: false,
 | 
					    showOnHover: false,
 | 
				
			||||||
    direction: 'horizontal',
 | 
					    direction: 'horizontal',
 | 
				
			||||||
 | 
					    showAside: undefined,
 | 
				
			||||||
    button: {label: 'Menu'},
 | 
					    button: {label: 'Menu'},
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -65,6 +67,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        handleDropdown({toggle: button, menu : menu,
 | 
					        handleDropdown({toggle: button, menu : menu,
 | 
				
			||||||
            showOnHover: this.options.showOnHover,
 | 
					            showOnHover: this.options.showOnHover,
 | 
				
			||||||
 | 
					            showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'),
 | 
				
			||||||
            onOpen : () => {
 | 
					            onOpen : () => {
 | 
				
			||||||
            this.open = true;
 | 
					            this.open = true;
 | 
				
			||||||
            this.getContext().manager.triggerStateUpdateForElement(this.button);
 | 
					            this.getContext().manager.triggerStateUpdateForElement(this.button);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,20 +1,48 @@
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface HandleDropdownParams {
 | 
					interface HandleDropdownParams {
 | 
				
			||||||
    toggle: HTMLElement;
 | 
					    toggle: HTMLElement;
 | 
				
			||||||
    menu: HTMLElement;
 | 
					    menu: HTMLElement;
 | 
				
			||||||
    showOnHover?: boolean,
 | 
					    showOnHover?: boolean,
 | 
				
			||||||
    onOpen?: Function | undefined;
 | 
					    onOpen?: Function | undefined;
 | 
				
			||||||
    onClose?: 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) {
 | 
					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;
 | 
					    let clickListener: Function|null = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const hide = () => {
 | 
					    const hide = () => {
 | 
				
			||||||
        menu.hidden = true;
 | 
					        menu.hidden = true;
 | 
				
			||||||
 | 
					        menu.style.removeProperty('position');
 | 
				
			||||||
 | 
					        menu.style.removeProperty('left');
 | 
				
			||||||
 | 
					        menu.style.removeProperty('top');
 | 
				
			||||||
        if (clickListener) {
 | 
					        if (clickListener) {
 | 
				
			||||||
            window.removeEventListener('click', clickListener as EventListener);
 | 
					            window.removeEventListener('click', clickListener as EventListener);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
| 
						 | 
					@ -25,6 +53,7 @@ export function handleDropdown(options: HandleDropdownParams) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const show = () => {
 | 
					    const show = () => {
 | 
				
			||||||
        menu.hidden = false
 | 
					        menu.hidden = false
 | 
				
			||||||
 | 
					        positionMenu(menu, toggle, Boolean(showAside));
 | 
				
			||||||
        clickListener = (event: MouseEvent) => {
 | 
					        clickListener = (event: MouseEvent) => {
 | 
				
			||||||
            if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) {
 | 
					            if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) {
 | 
				
			||||||
                hide();
 | 
					                hide();
 | 
				
			||||||
| 
						 | 
					@ -44,5 +73,18 @@ export function handleDropdown(options: HandleDropdownParams) {
 | 
				
			||||||
        toggle.addEventListener('mouseenter', toggleShowing);
 | 
					        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();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,7 @@
 | 
				
			||||||
import {EditorFormModal, EditorFormModalDefinition} from "./modals";
 | 
					import {EditorFormModal, EditorFormModalDefinition} from "./modals";
 | 
				
			||||||
import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
 | 
					import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
 | 
				
			||||||
import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
 | 
					import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
 | 
				
			||||||
import {BaseSelection, LexicalEditor} from "lexical";
 | 
					import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
 | 
				
			||||||
import {DecoratorListener} from "lexical/LexicalEditor";
 | 
					import {DecoratorListener} from "lexical/LexicalEditor";
 | 
				
			||||||
import type {NodeKey} from "lexical/LexicalNode";
 | 
					import type {NodeKey} from "lexical/LexicalNode";
 | 
				
			||||||
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
 | 
					import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
 | 
				
			||||||
| 
						 | 
					@ -231,6 +231,22 @@ export class EditorUIManager {
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        editor.registerDecoratorListener(domDecorateListener);
 | 
					        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) {
 | 
					    protected setupEventListeners(context: EditorUiContext) {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,10 +1,10 @@
 | 
				
			||||||
import {LexicalEditor} from "lexical";
 | 
					import {LexicalEditor} from "lexical";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    getCodeToolbarContent,
 | 
					    getCodeToolbarContent, getDetailsToolbarContent,
 | 
				
			||||||
    getImageToolbarContent,
 | 
					    getImageToolbarContent,
 | 
				
			||||||
    getLinkToolbarContent,
 | 
					    getLinkToolbarContent,
 | 
				
			||||||
    getMainEditorFullToolbar, getTableToolbarContent
 | 
					    getMainEditorFullToolbar, getTableToolbarContent
 | 
				
			||||||
} from "./toolbars";
 | 
					} from "./defaults/toolbars";
 | 
				
			||||||
import {EditorUIManager} from "./framework/manager";
 | 
					import {EditorUIManager} from "./framework/manager";
 | 
				
			||||||
import {EditorUiContext} from "./framework/core";
 | 
					import {EditorUiContext} from "./framework/core";
 | 
				
			||||||
import {CodeBlockDecorator} from "./decorators/code-block";
 | 
					import {CodeBlockDecorator} from "./decorators/code-block";
 | 
				
			||||||
| 
						 | 
					@ -56,7 +56,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
 | 
				
			||||||
        selector: '.editor-code-block-wrap',
 | 
					        selector: '.editor-code-block-wrap',
 | 
				
			||||||
        content: getCodeToolbarContent(),
 | 
					        content: getCodeToolbarContent(),
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					 | 
				
			||||||
    manager.registerContextToolbar('table', {
 | 
					    manager.registerContextToolbar('table', {
 | 
				
			||||||
        selector: 'td,th',
 | 
					        selector: 'td,th',
 | 
				
			||||||
        content: getTableToolbarContent(),
 | 
					        content: getTableToolbarContent(),
 | 
				
			||||||
| 
						 | 
					@ -64,6 +63,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
 | 
				
			||||||
            return originalTarget.closest('table') as HTMLTableElement;
 | 
					            return originalTarget.closest('table') as HTMLTableElement;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					    manager.registerContextToolbar('details', {
 | 
				
			||||||
 | 
					        selector: 'details',
 | 
				
			||||||
 | 
					        content: getDetailsToolbarContent(),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Register image decorator listener
 | 
					    // Register image decorator listener
 | 
				
			||||||
    manager.registerDecoratorType('code', CodeBlockDecorator);
 | 
					    manager.registerDecoratorType('code', CodeBlockDecorator);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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 = `<ul>
 | 
				
			||||||
 | 
					    <li>Inner A</li>
 | 
				
			||||||
 | 
					    <li>Inner B <ul>
 | 
				
			||||||
 | 
					            <li>Inner C</li>
 | 
				
			||||||
 | 
					    </ul></li>
 | 
				
			||||||
 | 
					</ul>`;
 | 
				
			||||||
 | 
					            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 = `<ul>
 | 
				
			||||||
 | 
					<li>Nested list:<ul>
 | 
				
			||||||
 | 
					    <li>Inner A</li>
 | 
				
			||||||
 | 
					    <li>Inner B</li>
 | 
				
			||||||
 | 
					    <li>Inner C</li>
 | 
				
			||||||
 | 
					</ul></li>
 | 
				
			||||||
 | 
					</ul>`;
 | 
				
			||||||
 | 
					            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'}]},
 | 
				
			||||||
 | 
					                                    ],
 | 
				
			||||||
 | 
					                                }
 | 
				
			||||||
 | 
					                            ],
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    ]
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            ]);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
| 
						 | 
					@ -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 {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
 | 
				
			||||||
import {nodeHasInset} from "./nodes";
 | 
					import {nodeHasInset} from "./nodes";
 | 
				
			||||||
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
 | 
					import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
 | 
				
			||||||
| 
						 | 
					@ -10,6 +10,9 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
 | 
				
			||||||
        return node;
 | 
					        return node;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null;
 | 
				
			||||||
 | 
					    const nodeChildItems = nodeChildList?.getChildren() || [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const listItems = list.getChildren() as ListItemNode[];
 | 
					    const listItems = list.getChildren() as ListItemNode[];
 | 
				
			||||||
    const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
 | 
					    const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
 | 
				
			||||||
    const isFirst = nodeIndex === 0;
 | 
					    const isFirst = nodeIndex === 0;
 | 
				
			||||||
| 
						 | 
					@ -27,6 +30,13 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
 | 
				
			||||||
        node.remove();
 | 
					        node.remove();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (nodeChildList) {
 | 
				
			||||||
 | 
					        for (const child of nodeChildItems) {
 | 
				
			||||||
 | 
					            newListItem.insertAfter(child);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        nodeChildList.remove();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return newListItem;
 | 
					    return newListItem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -38,6 +48,8 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
 | 
				
			||||||
        return node;
 | 
					        return node;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const laterSiblings = node.getNextSiblings();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    parentListItem.insertAfter(node);
 | 
					    parentListItem.insertAfter(node);
 | 
				
			||||||
    if (list.getChildren().length === 0) {
 | 
					    if (list.getChildren().length === 0) {
 | 
				
			||||||
        list.remove();
 | 
					        list.remove();
 | 
				
			||||||
| 
						 | 
					@ -47,6 +59,16 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
 | 
				
			||||||
        parentListItem.remove();
 | 
					        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;
 | 
					    return node;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -93,6 +115,7 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
 | 
					export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
 | 
				
			||||||
    const selection = $getSelection();
 | 
					    const selection = $getSelection();
 | 
				
			||||||
 | 
					    const selectionBounds = selection?.getStartEndPoints();
 | 
				
			||||||
    const listItemsInSelection = getListItemsForSelection(selection);
 | 
					    const listItemsInSelection = getListItemsForSelection(selection);
 | 
				
			||||||
    const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
 | 
					    const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -110,7 +133,19 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo
 | 
				
			||||||
            alteredListItems.reverse();
 | 
					            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;
 | 
					        return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -51,6 +51,10 @@ export function $getNodeFromSelection(selection: BaseSelection | null, matcher:
 | 
				
			||||||
    return null;
 | 
					    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 {
 | 
					export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean {
 | 
				
			||||||
    if (!selection) {
 | 
					    if (!selection) {
 | 
				
			||||||
        return false;
 | 
					        return false;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,18 +8,28 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Main UI elements
 | 
					// Main UI elements
 | 
				
			||||||
.editor-container {
 | 
					.editor-container {
 | 
				
			||||||
  background-color: #FFF;
 | 
					  @include mixins.lightDark(background-color, #FFF, #222);
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
  &.fullscreen {
 | 
					  &.fullscreen {
 | 
				
			||||||
    z-index: 500;
 | 
					    z-index: 500;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.editor-toolbar-main {
 | 
					.editor-toolbar-main {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  flex-wrap: wrap;
 | 
					  flex-wrap: wrap;
 | 
				
			||||||
  justify-content: center;
 | 
					  justify-content: center;
 | 
				
			||||||
  border-top: 1px solid #DDD;
 | 
					  border-top: 1px solid #DDD;
 | 
				
			||||||
  border-bottom: 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 {
 | 
					body.editor-is-fullscreen {
 | 
				
			||||||
| 
						 | 
					@ -38,6 +48,7 @@ body.editor-is-fullscreen {
 | 
				
			||||||
.editor-content-wrap {
 | 
					.editor-content-wrap {
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
  overflow-y: scroll;
 | 
					  overflow-y: scroll;
 | 
				
			||||||
 | 
					  padding-inline: vars.$s;
 | 
				
			||||||
  flex: 1;
 | 
					  flex: 1;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -46,6 +57,7 @@ body.editor-is-fullscreen {
 | 
				
			||||||
  font-size: 12px;
 | 
					  font-size: 12px;
 | 
				
			||||||
  padding: 4px;
 | 
					  padding: 4px;
 | 
				
			||||||
  color: #444;
 | 
					  color: #444;
 | 
				
			||||||
 | 
					  @include mixins.lightDark(color, #444, #999);
 | 
				
			||||||
  border-radius: 4px;
 | 
					  border-radius: 4px;
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
| 
						 | 
					@ -54,6 +66,7 @@ body.editor-is-fullscreen {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-button:hover {
 | 
					.editor-button:hover {
 | 
				
			||||||
  background-color: #EEE;
 | 
					  background-color: #EEE;
 | 
				
			||||||
 | 
					  @include mixins.lightDark(background-color, #EEE, #333);
 | 
				
			||||||
  cursor: pointer;
 | 
					  cursor: pointer;
 | 
				
			||||||
  color: #000;
 | 
					  color: #000;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -63,7 +76,7 @@ body.editor-is-fullscreen {
 | 
				
			||||||
  opacity: .6;
 | 
					  opacity: .6;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-button-active, .editor-button-active:hover {
 | 
					.editor-button-active, .editor-button-active:hover {
 | 
				
			||||||
  background-color: #ceebff;
 | 
					  @include mixins.lightDark(background-color, #ceebff, #444);
 | 
				
			||||||
  color: #000;
 | 
					  color: #000;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-button-long {
 | 
					.editor-button-long {
 | 
				
			||||||
| 
						 | 
					@ -75,7 +88,7 @@ body.editor-is-fullscreen {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-button-text {
 | 
					.editor-button-text {
 | 
				
			||||||
  font-weight: 400;
 | 
					  font-weight: 400;
 | 
				
			||||||
  color: #000;
 | 
					  @include mixins.lightDark(color, #000, #AAA);
 | 
				
			||||||
  font-size: 14px;
 | 
					  font-size: 14px;
 | 
				
			||||||
  flex: 1;
 | 
					  flex: 1;
 | 
				
			||||||
  padding-inline-end: 4px;
 | 
					  padding-inline-end: 4px;
 | 
				
			||||||
| 
						 | 
					@ -126,7 +139,8 @@ body.editor-is-fullscreen {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  &:hover {
 | 
					  &:hover {
 | 
				
			||||||
    outline: 1px solid #DDD;
 | 
					    outline: 1px solid;
 | 
				
			||||||
 | 
					    @include mixins.lightDark(outline-color, #DDD, #111);
 | 
				
			||||||
    outline-offset: -3px;
 | 
					    outline-offset: -3px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -137,11 +151,14 @@ body.editor-is-fullscreen {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-dropdown-menu {
 | 
					.editor-dropdown-menu {
 | 
				
			||||||
  position: absolute;
 | 
					  position: absolute;
 | 
				
			||||||
  background-color: #FFF;
 | 
					  border: 1px solid;
 | 
				
			||||||
  box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15);
 | 
					  @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;
 | 
					  z-index: 99;
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  flex-direction: row;
 | 
					  flex-direction: row;
 | 
				
			||||||
 | 
					  border-radius: 3px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-dropdown-menu-vertical {
 | 
					.editor-dropdown-menu-vertical {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
| 
						 | 
					@ -163,8 +180,8 @@ body.editor-is-fullscreen {
 | 
				
			||||||
.editor-separator {
 | 
					.editor-separator {
 | 
				
			||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
  height: 1px;
 | 
					  height: 1px;
 | 
				
			||||||
  background-color: #DDD;
 | 
					 | 
				
			||||||
  opacity: .8;
 | 
					  opacity: .8;
 | 
				
			||||||
 | 
					  @include mixins.lightDark(background-color, #DDD, #000);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.editor-format-menu-toggle {
 | 
					.editor-format-menu-toggle {
 | 
				
			||||||
| 
						 | 
					@ -199,6 +216,7 @@ body.editor-is-fullscreen {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  border-inline: 1px solid #DDD;
 | 
					  border-inline: 1px solid #DDD;
 | 
				
			||||||
  padding-inline: 4px;
 | 
					  padding-inline: 4px;
 | 
				
			||||||
 | 
					  @include mixins.lightDark(border-color, #DDD, #000);
 | 
				
			||||||
  &:first-child {
 | 
					  &:first-child {
 | 
				
			||||||
    border-inline-start: none;
 | 
					    border-inline-start: none;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -212,11 +230,12 @@ body.editor-is-fullscreen {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.editor-context-toolbar {
 | 
					.editor-context-toolbar {
 | 
				
			||||||
  position: fixed;
 | 
					  position: fixed;
 | 
				
			||||||
  background-color: #FFF;
 | 
					 | 
				
			||||||
  border: 1px solid #DDD;
 | 
					  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;
 | 
					  padding: .2rem;
 | 
				
			||||||
  border-radius: 4px;
 | 
					  border-radius: 4px;
 | 
				
			||||||
  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12);
 | 
					 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  flex-direction: row;
 | 
					  flex-direction: row;
 | 
				
			||||||
  &:before {
 | 
					  &:before {
 | 
				
			||||||
| 
						 | 
					@ -226,9 +245,10 @@ body.editor-is-fullscreen {
 | 
				
			||||||
    width: 8px;
 | 
					    width: 8px;
 | 
				
			||||||
    height: 8px;
 | 
					    height: 8px;
 | 
				
			||||||
    position: absolute;
 | 
					    position: absolute;
 | 
				
			||||||
    background-color: #FFF;
 | 
					    @include mixins.lightDark(background-color, #FFF, #222);
 | 
				
			||||||
    border-top: 1px solid #DDD;
 | 
					    border-top: 1px solid #DDD;
 | 
				
			||||||
    border-left: 1px solid #DDD;
 | 
					    border-left: 1px solid #DDD;
 | 
				
			||||||
 | 
					    @include mixins.lightDark(border-color, #DDD, #333);
 | 
				
			||||||
    transform: rotate(45deg);
 | 
					    transform: rotate(45deg);
 | 
				
			||||||
    left: 50%;
 | 
					    left: 50%;
 | 
				
			||||||
    margin-left: -4px;
 | 
					    margin-left: -4px;
 | 
				
			||||||
| 
						 | 
					@ -252,10 +272,13 @@ body.editor-is-fullscreen {
 | 
				
			||||||
  height: 100%;
 | 
					  height: 100%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-modal {
 | 
					.editor-modal {
 | 
				
			||||||
  background-color: #FFF;
 | 
					  @include mixins.lightDark(background-color, #FFF, #222);
 | 
				
			||||||
  border-radius: 4px;
 | 
					  border-radius: 4px;
 | 
				
			||||||
  overflow: hidden;
 | 
					  overflow: hidden;
 | 
				
			||||||
  box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3);
 | 
					  box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3);
 | 
				
			||||||
 | 
					  margin: vars.$xs;
 | 
				
			||||||
 | 
					  max-height: 100%;
 | 
				
			||||||
 | 
					  overflow-y: auto;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-modal-header {
 | 
					.editor-modal-header {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
| 
						 | 
					@ -314,7 +337,8 @@ body.editor-is-fullscreen {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-table-creator-cell {
 | 
					.editor-table-creator-cell {
 | 
				
			||||||
  border: 1px solid #DDD;
 | 
					  border: 1px solid;
 | 
				
			||||||
 | 
					  @include mixins.lightDark(border-color, #DDD, #000);
 | 
				
			||||||
  width: 15px;
 | 
					  width: 15px;
 | 
				
			||||||
  height: 15px;
 | 
					  height: 15px;
 | 
				
			||||||
  cursor: pointer;
 | 
					  cursor: pointer;
 | 
				
			||||||
| 
						 | 
					@ -326,6 +350,13 @@ body.editor-is-fullscreen {
 | 
				
			||||||
  text-align: center;
 | 
					  text-align: center;
 | 
				
			||||||
  padding: 0.2em;
 | 
					  padding: 0.2em;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					.editor-external-content {
 | 
				
			||||||
 | 
					  min-width: 500px;
 | 
				
			||||||
 | 
					  min-height: 500px;
 | 
				
			||||||
 | 
					  h4:first-child {
 | 
				
			||||||
 | 
					    margin-top: 0;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// In-editor elements
 | 
					// In-editor elements
 | 
				
			||||||
.editor-image-wrap {
 | 
					.editor-image-wrap {
 | 
				
			||||||
| 
						 | 
					@ -347,7 +378,7 @@ body.editor-is-fullscreen {
 | 
				
			||||||
  height: 10px;
 | 
					  height: 10px;
 | 
				
			||||||
  border: 2px solid var(--editor-color-primary);
 | 
					  border: 2px solid var(--editor-color-primary);
 | 
				
			||||||
  z-index: 3;
 | 
					  z-index: 3;
 | 
				
			||||||
  background-color: #FFF;
 | 
					  @include mixins.lightDark(background-color, #FFF, #000);
 | 
				
			||||||
  user-select: none;
 | 
					  user-select: none;
 | 
				
			||||||
  &.nw {
 | 
					  &.nw {
 | 
				
			||||||
    inset-inline-start: -5px;
 | 
					    inset-inline-start: -5px;
 | 
				
			||||||
| 
						 | 
					@ -470,18 +501,29 @@ body.editor-is-fullscreen {
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Form elements
 | 
					 * Form elements
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					$inputWidth: 260px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.editor-form-field-wrapper {
 | 
					.editor-form-field-wrapper {
 | 
				
			||||||
  margin-bottom: .5rem;
 | 
					  margin-bottom: .5rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-form-field-input {
 | 
					.editor-form-field-input {
 | 
				
			||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
  width: 100%;
 | 
					  width: $inputWidth;
 | 
				
			||||||
  min-width: 250px;
 | 
					  min-width: 100px;
 | 
				
			||||||
  border: 1px solid #DDD;
 | 
					  max-width: 100%;
 | 
				
			||||||
 | 
					  border: 1px solid;
 | 
				
			||||||
 | 
					  @include mixins.lightDark(border-color, #DDD, #000);
 | 
				
			||||||
  padding: .5rem;
 | 
					  padding: .5rem;
 | 
				
			||||||
  border-radius: 4px;
 | 
					  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 {
 | 
					textarea.editor-form-field-input {
 | 
				
			||||||
  font-family: var(--font-code);
 | 
					  font-family: var(--font-code);
 | 
				
			||||||
  width: 350px;
 | 
					  width: 350px;
 | 
				
			||||||
| 
						 | 
					@ -554,10 +596,21 @@ textarea.editor-form-field-input {
 | 
				
			||||||
  align-items: stretch;
 | 
					  align-items: stretch;
 | 
				
			||||||
  gap: .25rem;
 | 
					  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 {
 | 
					.editor-form-tab-control {
 | 
				
			||||||
  font-weight: bold;
 | 
					  font-weight: bold;
 | 
				
			||||||
  font-size: 14px;
 | 
					  font-size: 14px;
 | 
				
			||||||
  color: #444;
 | 
					  @include mixins.lightDark(color, #444, #666);
 | 
				
			||||||
  border-bottom: 2px solid transparent;
 | 
					  border-bottom: 2px solid transparent;
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
  cursor: pointer;
 | 
					  cursor: pointer;
 | 
				
			||||||
| 
						 | 
					@ -565,7 +618,7 @@ textarea.editor-form-field-input {
 | 
				
			||||||
  text-align: start;
 | 
					  text-align: start;
 | 
				
			||||||
  &[aria-selected="true"] {
 | 
					  &[aria-selected="true"] {
 | 
				
			||||||
    border-color: var(--editor-color-primary);
 | 
					    border-color: var(--editor-color-primary);
 | 
				
			||||||
    color: var(--editor-color-primary);
 | 
					    color: var(--editor-color-primary) !important;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  &[aria-selected="true"]:after, &:hover:after {
 | 
					  &[aria-selected="true"]:after, &:hover:after {
 | 
				
			||||||
    background-color: var(--editor-color-primary);
 | 
					    background-color: var(--editor-color-primary);
 | 
				
			||||||
| 
						 | 
					@ -580,7 +633,8 @@ textarea.editor-form-field-input {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-form-tab-contents {
 | 
					.editor-form-tab-contents {
 | 
				
			||||||
  width: 360px;
 | 
					  width: $inputWidth;
 | 
				
			||||||
 | 
					  max-width: 100%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
.editor-action-input-container {
 | 
					.editor-action-input-container {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
| 
						 | 
					@ -591,6 +645,9 @@ textarea.editor-form-field-input {
 | 
				
			||||||
  .editor-button {
 | 
					  .editor-button {
 | 
				
			||||||
    margin-bottom: 12px;
 | 
					    margin-bottom: 12px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					  input {
 | 
				
			||||||
 | 
					    width: $inputWidth - 40px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Editor theme styles
 | 
					// Editor theme styles
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,6 +26,7 @@
 | 
				
			||||||
  width: 100%;
 | 
					  width: 100%;
 | 
				
			||||||
  border-radius: 8px;
 | 
					  border-radius: 8px;
 | 
				
			||||||
  box-shadow: vars.$bs-card;
 | 
					  box-shadow: vars.$bs-card;
 | 
				
			||||||
 | 
					  min-width: 300px;
 | 
				
			||||||
  @include mixins.lightDark(background-color, #FFF, #333)
 | 
					  @include mixins.lightDark(background-color, #FFF, #333)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,146 @@
 | 
				
			||||||
 | 
					@extends('layouts.plain')
 | 
				
			||||||
 | 
					@section('document-class', 'bg-white ' .  (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : ''))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@section('content')
 | 
				
			||||||
 | 
					    <div class="p-m">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <h4 class="mt-s">{{ trans('editor.editor_license') }}</h4>
 | 
				
			||||||
 | 
					        <p>
 | 
				
			||||||
 | 
					            {!! trans('editor.editor_tiny_license', ['tinyLink' => '<a href="https://www.tiny.cloud/" target="_blank" rel="noopener noreferrer">TinyMCE</a>']) !!}
 | 
				
			||||||
 | 
					            <br>
 | 
				
			||||||
 | 
					            <a href="{{ url('/libs/tinymce/license.txt') }}" target="_blank">{{ trans('editor.editor_tiny_license_link') }}</a>
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <h4>{{ trans('editor.shortcuts') }}</h4>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <p>{{ trans('editor.shortcuts_intro') }}</p>
 | 
				
			||||||
 | 
					        <table>
 | 
				
			||||||
 | 
					            <thead>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <th>{{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}</th>
 | 
				
			||||||
 | 
					                <th>{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}</th>
 | 
				
			||||||
 | 
					                <th>{{ trans('editor.description') }}</th>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            </thead>
 | 
				
			||||||
 | 
					            <tbody>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td><code>Ctrl</code>+<code>S</code></td>
 | 
				
			||||||
 | 
					                <td><code>Cmd</code>+<code>S</code></td>
 | 
				
			||||||
 | 
					                <td>{{ trans('entities.pages_edit_save_draft') }}</td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td><code>Ctrl</code>+<code>Enter</code></td>
 | 
				
			||||||
 | 
					                <td><code>Cmd</code>+<code>Enter</code></td>
 | 
				
			||||||
 | 
					                <td>{{ trans('editor.save_continue') }}</td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td><code>Ctrl</code>+<code>B</code></td>
 | 
				
			||||||
 | 
					                <td><code>Cmd</code>+<code>B</code></td>
 | 
				
			||||||
 | 
					                <td>{{ trans('editor.bold') }}</td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td><code>Ctrl</code>+<code>I</code></td>
 | 
				
			||||||
 | 
					                <td><code>Cmd</code>+<code>I</code></td>
 | 
				
			||||||
 | 
					                <td>{{ trans('editor.italic') }}</td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>1</code><br>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>2</code><br>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>3</code><br>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>4</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>1</code><br>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>2</code><br>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>3</code><br>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>4</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    {{ trans('editor.header_large') }} <br>
 | 
				
			||||||
 | 
					                    {{ trans('editor.header_medium') }} <br>
 | 
				
			||||||
 | 
					                    {{ trans('editor.header_small') }} <br>
 | 
				
			||||||
 | 
					                    {{ trans('editor.header_tiny') }}
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>5</code><br>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>D</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>5</code><br>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>D</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>{{ trans('editor.paragraph') }}</td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>6</code><br>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>Q</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>6</code><br>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>Q</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>{{ trans('editor.blockquote') }}</td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>7</code><br>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>E</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>7</code><br>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>E</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>{{ trans('editor.insert_code_block') }}</td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>8</code><br>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>Shift</code>+<code>E</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>8</code><br>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>Shift</code>+<code>E</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>{{ trans('editor.inline_code') }}</td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td><code>Ctrl</code>+<code>9</code></td>
 | 
				
			||||||
 | 
					                <td><code>Cmd</code>+<code>9</code></td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    {{ trans('editor.callouts') }} <br>
 | 
				
			||||||
 | 
					                    <small>{{ trans('editor.callouts_cycle') }}</small>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>O</code> <br>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>P</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>O</code> <br>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>P</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    {{ trans('editor.list_numbered') }} <br>
 | 
				
			||||||
 | 
					                    {{ trans('editor.list_bullet') }}
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Ctrl</code>+<code>Shift</code>+<code>K</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>
 | 
				
			||||||
 | 
					                    <code>Cmd</code>+<code>Shift</code>+<code>K</code>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td>{{ trans('editor.link_selector') }}</td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					            </tbody>
 | 
				
			||||||
 | 
					        </table>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					@endsection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,146 +1,138 @@
 | 
				
			||||||
@extends('layouts.plain')
 | 
					<h4>{{ trans('editor.shortcuts') }}</h4>
 | 
				
			||||||
@section('document-class', 'bg-white ' .  (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : ''))
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@section('content')
 | 
					<p>{{ trans('editor.shortcuts_intro') }}</p>
 | 
				
			||||||
    <div class="p-m">
 | 
					<table>
 | 
				
			||||||
 | 
					    <thead>
 | 
				
			||||||
        <h4 class="mt-s">{{ trans('editor.editor_license') }}</h4>
 | 
					    <tr>
 | 
				
			||||||
        <p>
 | 
					        <th>{{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}</th>
 | 
				
			||||||
            {!! trans('editor.editor_tiny_license', ['tinyLink' => '<a href="https://www.tiny.cloud/" target="_blank" rel="noopener noreferrer">TinyMCE</a>']) !!}
 | 
					        <th>{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}</th>
 | 
				
			||||||
            <br>
 | 
					        <th>{{ trans('editor.description') }}</th>
 | 
				
			||||||
            <a href="{{ url('/libs/tinymce/license.txt') }}" target="_blank">{{ trans('editor.editor_tiny_license_link') }}</a>
 | 
					    </tr>
 | 
				
			||||||
        </p>
 | 
					    </thead>
 | 
				
			||||||
 | 
					    <tbody>
 | 
				
			||||||
        <h4>{{ trans('editor.shortcuts') }}</h4>
 | 
					    <tr>
 | 
				
			||||||
 | 
					        <td><code>Ctrl</code>+<code>S</code></td>
 | 
				
			||||||
        <p>{{ trans('editor.shortcuts_intro') }}</p>
 | 
					        <td><code>Cmd</code>+<code>S</code></td>
 | 
				
			||||||
        <table>
 | 
					        <td>{{ trans('entities.pages_edit_save_draft') }}</td>
 | 
				
			||||||
            <thead>
 | 
					    </tr>
 | 
				
			||||||
            <tr>
 | 
					    <tr>
 | 
				
			||||||
                <th>{{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}</th>
 | 
					        <td><code>Ctrl</code>+<code>Enter</code></td>
 | 
				
			||||||
                <th>{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}</th>
 | 
					        <td><code>Cmd</code>+<code>Enter</code></td>
 | 
				
			||||||
                <th>{{ trans('editor.description') }}</th>
 | 
					        <td>{{ trans('editor.save_continue') }}</td>
 | 
				
			||||||
            </tr>
 | 
					    </tr>
 | 
				
			||||||
            </thead>
 | 
					    <tr>
 | 
				
			||||||
            <tbody>
 | 
					        <td><code>Ctrl</code>+<code>B</code></td>
 | 
				
			||||||
            <tr>
 | 
					        <td><code>Cmd</code>+<code>B</code></td>
 | 
				
			||||||
                <td><code>Ctrl</code>+<code>S</code></td>
 | 
					        <td>{{ trans('editor.bold') }}</td>
 | 
				
			||||||
                <td><code>Cmd</code>+<code>S</code></td>
 | 
					    </tr>
 | 
				
			||||||
                <td>{{ trans('entities.pages_edit_save_draft') }}</td>
 | 
					    <tr>
 | 
				
			||||||
            </tr>
 | 
					        <td><code>Ctrl</code>+<code>I</code></td>
 | 
				
			||||||
            <tr>
 | 
					        <td><code>Cmd</code>+<code>I</code></td>
 | 
				
			||||||
                <td><code>Ctrl</code>+<code>Enter</code></td>
 | 
					        <td>{{ trans('editor.italic') }}</td>
 | 
				
			||||||
                <td><code>Cmd</code>+<code>Enter</code></td>
 | 
					    </tr>
 | 
				
			||||||
                <td>{{ trans('editor.save_continue') }}</td>
 | 
					    <tr>
 | 
				
			||||||
            </tr>
 | 
					        <td>
 | 
				
			||||||
            <tr>
 | 
					            <code>Ctrl</code>+<code>1</code><br>
 | 
				
			||||||
                <td><code>Ctrl</code>+<code>B</code></td>
 | 
					            <code>Ctrl</code>+<code>2</code><br>
 | 
				
			||||||
                <td><code>Cmd</code>+<code>B</code></td>
 | 
					            <code>Ctrl</code>+<code>3</code><br>
 | 
				
			||||||
                <td>{{ trans('editor.bold') }}</td>
 | 
					            <code>Ctrl</code>+<code>4</code>
 | 
				
			||||||
            </tr>
 | 
					        </td>
 | 
				
			||||||
            <tr>
 | 
					        <td>
 | 
				
			||||||
                <td><code>Ctrl</code>+<code>I</code></td>
 | 
					            <code>Cmd</code>+<code>1</code><br>
 | 
				
			||||||
                <td><code>Cmd</code>+<code>I</code></td>
 | 
					            <code>Cmd</code>+<code>2</code><br>
 | 
				
			||||||
                <td>{{ trans('editor.italic') }}</td>
 | 
					            <code>Cmd</code>+<code>3</code><br>
 | 
				
			||||||
            </tr>
 | 
					            <code>Cmd</code>+<code>4</code>
 | 
				
			||||||
            <tr>
 | 
					        </td>
 | 
				
			||||||
                <td>
 | 
					        <td>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>1</code><br>
 | 
					            {{ trans('editor.header_large') }} <br>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>2</code><br>
 | 
					            {{ trans('editor.header_medium') }} <br>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>3</code><br>
 | 
					            {{ trans('editor.header_small') }} <br>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>4</code>
 | 
					            {{ trans('editor.header_tiny') }}
 | 
				
			||||||
                </td>
 | 
					        </td>
 | 
				
			||||||
                <td>
 | 
					    </tr>
 | 
				
			||||||
                    <code>Cmd</code>+<code>1</code><br>
 | 
					    <tr>
 | 
				
			||||||
                    <code>Cmd</code>+<code>2</code><br>
 | 
					        <td>
 | 
				
			||||||
                    <code>Cmd</code>+<code>3</code><br>
 | 
					            <code>Ctrl</code>+<code>5</code><br>
 | 
				
			||||||
                    <code>Cmd</code>+<code>4</code>
 | 
					            <code>Ctrl</code>+<code>D</code>
 | 
				
			||||||
                </td>
 | 
					        </td>
 | 
				
			||||||
                <td>
 | 
					        <td>
 | 
				
			||||||
                    {{ trans('editor.header_large') }} <br>
 | 
					            <code>Cmd</code>+<code>5</code><br>
 | 
				
			||||||
                    {{ trans('editor.header_medium') }} <br>
 | 
					            <code>Cmd</code>+<code>D</code>
 | 
				
			||||||
                    {{ trans('editor.header_small') }} <br>
 | 
					        </td>
 | 
				
			||||||
                    {{ trans('editor.header_tiny') }}
 | 
					        <td>{{ trans('editor.paragraph') }}</td>
 | 
				
			||||||
                </td>
 | 
					    </tr>
 | 
				
			||||||
            </tr>
 | 
					    <tr>
 | 
				
			||||||
            <tr>
 | 
					        <td>
 | 
				
			||||||
                <td>
 | 
					            <code>Ctrl</code>+<code>6</code><br>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>5</code><br>
 | 
					            <code>Ctrl</code>+<code>Q</code>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>D</code>
 | 
					        </td>
 | 
				
			||||||
                </td>
 | 
					        <td>
 | 
				
			||||||
                <td>
 | 
					            <code>Cmd</code>+<code>6</code><br>
 | 
				
			||||||
                    <code>Cmd</code>+<code>5</code><br>
 | 
					            <code>Cmd</code>+<code>Q</code>
 | 
				
			||||||
                    <code>Cmd</code>+<code>D</code>
 | 
					        </td>
 | 
				
			||||||
                </td>
 | 
					        <td>{{ trans('editor.blockquote') }}</td>
 | 
				
			||||||
                <td>{{ trans('editor.paragraph') }}</td>
 | 
					    </tr>
 | 
				
			||||||
            </tr>
 | 
					    <tr>
 | 
				
			||||||
            <tr>
 | 
					        <td>
 | 
				
			||||||
                <td>
 | 
					            <code>Ctrl</code>+<code>7</code><br>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>6</code><br>
 | 
					            <code>Ctrl</code>+<code>E</code>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>Q</code>
 | 
					        </td>
 | 
				
			||||||
                </td>
 | 
					        <td>
 | 
				
			||||||
                <td>
 | 
					            <code>Cmd</code>+<code>7</code><br>
 | 
				
			||||||
                    <code>Cmd</code>+<code>6</code><br>
 | 
					            <code>Cmd</code>+<code>E</code>
 | 
				
			||||||
                    <code>Cmd</code>+<code>Q</code>
 | 
					        </td>
 | 
				
			||||||
                </td>
 | 
					        <td>{{ trans('editor.insert_code_block') }}</td>
 | 
				
			||||||
                <td>{{ trans('editor.blockquote') }}</td>
 | 
					    </tr>
 | 
				
			||||||
            </tr>
 | 
					    <tr>
 | 
				
			||||||
            <tr>
 | 
					        <td>
 | 
				
			||||||
                <td>
 | 
					            <code>Ctrl</code>+<code>8</code><br>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>7</code><br>
 | 
					            <code>Ctrl</code>+<code>Shift</code>+<code>E</code>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>E</code>
 | 
					        </td>
 | 
				
			||||||
                </td>
 | 
					        <td>
 | 
				
			||||||
                <td>
 | 
					            <code>Cmd</code>+<code>8</code><br>
 | 
				
			||||||
                    <code>Cmd</code>+<code>7</code><br>
 | 
					            <code>Cmd</code>+<code>Shift</code>+<code>E</code>
 | 
				
			||||||
                    <code>Cmd</code>+<code>E</code>
 | 
					        </td>
 | 
				
			||||||
                </td>
 | 
					        <td>{{ trans('editor.inline_code') }}</td>
 | 
				
			||||||
                <td>{{ trans('editor.insert_code_block') }}</td>
 | 
					    </tr>
 | 
				
			||||||
            </tr>
 | 
					    <tr>
 | 
				
			||||||
            <tr>
 | 
					        <td><code>Ctrl</code>+<code>9</code></td>
 | 
				
			||||||
                <td>
 | 
					        <td><code>Cmd</code>+<code>9</code></td>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>8</code><br>
 | 
					        <td>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>Shift</code>+<code>E</code>
 | 
					            {{ trans('editor.callouts') }} <br>
 | 
				
			||||||
                </td>
 | 
					            <small>{{ trans('editor.callouts_cycle') }}</small>
 | 
				
			||||||
                <td>
 | 
					        </td>
 | 
				
			||||||
                    <code>Cmd</code>+<code>8</code><br>
 | 
					    </tr>
 | 
				
			||||||
                    <code>Cmd</code>+<code>Shift</code>+<code>E</code>
 | 
					    <tr>
 | 
				
			||||||
                </td>
 | 
					        <td>
 | 
				
			||||||
                <td>{{ trans('editor.inline_code') }}</td>
 | 
					            <code>Ctrl</code>+<code>O</code> <br>
 | 
				
			||||||
            </tr>
 | 
					            <code>Ctrl</code>+<code>P</code>
 | 
				
			||||||
            <tr>
 | 
					        </td>
 | 
				
			||||||
                <td><code>Ctrl</code>+<code>9</code></td>
 | 
					        <td>
 | 
				
			||||||
                <td><code>Cmd</code>+<code>9</code></td>
 | 
					            <code>Cmd</code>+<code>O</code> <br>
 | 
				
			||||||
                <td>
 | 
					            <code>Cmd</code>+<code>P</code>
 | 
				
			||||||
                    {{ trans('editor.callouts') }} <br>
 | 
					        </td>
 | 
				
			||||||
                    <small>{{ trans('editor.callouts_cycle') }}</small>
 | 
					        <td>
 | 
				
			||||||
                </td>
 | 
					            {{ trans('editor.list_numbered') }} <br>
 | 
				
			||||||
            </tr>
 | 
					            {{ trans('editor.list_bullet') }}
 | 
				
			||||||
            <tr>
 | 
					        </td>
 | 
				
			||||||
                <td>
 | 
					    </tr>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>O</code> <br>
 | 
					    <tr>
 | 
				
			||||||
                    <code>Ctrl</code>+<code>P</code>
 | 
					        <td>
 | 
				
			||||||
                </td>
 | 
					            <code>Ctrl</code>+<code>Shift</code>+<code>K</code>
 | 
				
			||||||
                <td>
 | 
					        </td>
 | 
				
			||||||
                    <code>Cmd</code>+<code>O</code> <br>
 | 
					        <td>
 | 
				
			||||||
                    <code>Cmd</code>+<code>P</code>
 | 
					            <code>Cmd</code>+<code>Shift</code>+<code>K</code>
 | 
				
			||||||
                </td>
 | 
					        </td>
 | 
				
			||||||
                <td>
 | 
					        <td>{{ trans('editor.link_selector') }}</td>
 | 
				
			||||||
                    {{ trans('editor.list_numbered') }} <br>
 | 
					    </tr>
 | 
				
			||||||
                    {{ trans('editor.list_bullet') }}
 | 
					    </tbody>
 | 
				
			||||||
                </td>
 | 
					</table>
 | 
				
			||||||
            </tr>
 | 
					 | 
				
			||||||
            <tr>
 | 
					 | 
				
			||||||
                <td>
 | 
					 | 
				
			||||||
                    <code>Ctrl</code>+<code>Shift</code>+<code>K</code>
 | 
					 | 
				
			||||||
                </td>
 | 
					 | 
				
			||||||
                <td>
 | 
					 | 
				
			||||||
                    <code>Cmd</code>+<code>Shift</code>+<code>K</code>
 | 
					 | 
				
			||||||
                </td>
 | 
					 | 
				
			||||||
                <td>{{ trans('editor.link_selector') }}</td>
 | 
					 | 
				
			||||||
            </tr>
 | 
					 | 
				
			||||||
            </tbody>
 | 
					 | 
				
			||||||
        </table>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
@endsection
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<h4 class="mt-s">{{ trans('editor.editor_license') }}</h4>
 | 
				
			||||||
 | 
					<p>
 | 
				
			||||||
 | 
					    {!! trans('editor.editor_lexical_license', ['lexicalLink' => '<a href="https://lexical.dev/" target="_blank" rel="noopener noreferrer">Lexical</a>']) !!}
 | 
				
			||||||
 | 
					    <br>
 | 
				
			||||||
 | 
					    <em class="text-muted">Copyright (c) Meta Platforms, Inc. and affiliates.</em>
 | 
				
			||||||
 | 
					    <br>
 | 
				
			||||||
 | 
					    <a href="{{ url('/licenses') }}" target="_blank">{{ trans('editor.editor_lexical_license_link') }}</a>
 | 
				
			||||||
 | 
					</p>
 | 
				
			||||||
| 
						 | 
					@ -361,6 +361,7 @@ Route::get('/password/reset/{token}', [AccessControllers\ResetPasswordController
 | 
				
			||||||
Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public');
 | 
					Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Metadata routes
 | 
					// Metadata routes
 | 
				
			||||||
 | 
					Route::view('/help/tinymce', 'help.tinymce');
 | 
				
			||||||
Route::view('/help/wysiwyg', 'help.wysiwyg');
 | 
					Route::view('/help/wysiwyg', 'help.wysiwyg');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Route::fallback([MetaController::class, 'notFound'])->name('fallback');
 | 
					Route::fallback([MetaController::class, 'notFound'])->name('fallback');
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,9 +6,9 @@ use Tests\TestCase;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class HelpTest extends TestCase
 | 
					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();
 | 
					        $resp->assertOk();
 | 
				
			||||||
        $this->withHtml($resp)->assertElementExists('a[href="https://www.tiny.cloud/"]');
 | 
					        $this->withHtml($resp)->assertElementExists('a[href="https://www.tiny.cloud/"]');
 | 
				
			||||||
        $this->withHtml($resp)->assertElementExists('a[href="' . url('/libs/tinymce/license.txt') . '"]');
 | 
					        $this->withHtml($resp)->assertElementExists('a[href="' . url('/libs/tinymce/license.txt') . '"]');
 | 
				
			||||||
| 
						 | 
					@ -22,4 +22,12 @@ class HelpTest extends TestCase
 | 
				
			||||||
        $contents = file_get_contents($expectedPath);
 | 
					        $contents = file_get_contents($expectedPath);
 | 
				
			||||||
        $this->assertStringContainsString('MIT License', $contents);
 | 
					        $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') . '"]');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue