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
 | 
			
		||||
  transform: {
 | 
			
		||||
    "^.+.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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -163,6 +163,8 @@ return [
 | 
			
		|||
    'about' => 'About the editor',
 | 
			
		||||
    'about_title' => 'About the WYSIWYG Editor',
 | 
			
		||||
    'editor_license' => 'Editor License & Copyright',
 | 
			
		||||
    'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
 | 
			
		||||
    'editor_lexical_license_link' => 'Full license details can be found here.',
 | 
			
		||||
    'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.',
 | 
			
		||||
    'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.',
 | 
			
		||||
    'save_continue' => 'Save Page & Continue',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
		 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) {
 | 
			
		||||
    const aboutDialog = {
 | 
			
		||||
        title: 'About the WYSIWYG Editor',
 | 
			
		||||
        url: window.baseUrl('/help/wysiwyg'),
 | 
			
		||||
        url: window.baseUrl('/help/tinymce'),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    editor.ui.registry.addButton('about', {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,7 @@ import {el} from "./utils/dom";
 | 
			
		|||
import {registerShortcuts} from "./services/shortcuts";
 | 
			
		||||
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
 | 
			
		||||
import {registerKeyboardHandling} from "./services/keyboard-handling";
 | 
			
		||||
import {registerAutoLinks} from "./services/auto-links";
 | 
			
		||||
 | 
			
		||||
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
 | 
			
		||||
    const config: CreateEditorArgs = {
 | 
			
		||||
| 
						 | 
				
			
			@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
 | 
			
		|||
        registerTaskListHandler(editor, editArea),
 | 
			
		||||
        registerDropPasteHandling(context),
 | 
			
		||||
        registerNodeResizer(context),
 | 
			
		||||
        registerAutoLinks(editor),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    listenToCommonEvents(editor);
 | 
			
		||||
| 
						 | 
				
			
			@ -73,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
 | 
			
		|||
    const debugView = document.getElementById('lexical-debug');
 | 
			
		||||
    if (debugView) {
 | 
			
		||||
        debugView.hidden = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let changeFromLoading = true;
 | 
			
		||||
    editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
 | 
			
		||||
        // Watch for selection changes to update the UI on change
 | 
			
		||||
        // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
 | 
			
		||||
        // for all selection changes, so this proved more reliable.
 | 
			
		||||
        const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
 | 
			
		||||
        if (selectionChange) {
 | 
			
		||||
            editor.update(() => {
 | 
			
		||||
                const selection = $getSelection();
 | 
			
		||||
                context.manager.triggerStateUpdate({
 | 
			
		||||
                    editor, selection,
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Emit change event to component system (for draft detection) on actual user content change
 | 
			
		||||
        if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
 | 
			
		||||
            if (changeFromLoading) {
 | 
			
		||||
                changeFromLoading = false;
 | 
			
		||||
            } else {
 | 
			
		||||
                window.$events.emit('editor-html-change', '');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Debug logic
 | 
			
		||||
        // console.log('editorState', editorState.toJSON());
 | 
			
		||||
        if (debugView) {
 | 
			
		||||
        editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
 | 
			
		||||
            // Debug logic
 | 
			
		||||
            // console.log('editorState', editorState.toJSON());
 | 
			
		||||
            debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    window.debugEditorState = () => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1188,6 +1188,14 @@ export class LexicalEditor {
 | 
			
		|||
    updateEditor(this, updateFn, options);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Helper to run the update and commitUpdates methods in a single call.
 | 
			
		||||
   */
 | 
			
		||||
  updateAndCommit(updateFn: () => void, options?: EditorUpdateOptions): void {
 | 
			
		||||
    this.update(updateFn, options);
 | 
			
		||||
    this.commitUpdates();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Focuses the editor
 | 
			
		||||
   * @param callbackFn - A function to run after the editor is focused.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -142,10 +142,15 @@ export type DOMConversionMap<T extends HTMLElement = HTMLElement> = Record<
 | 
			
		|||
>;
 | 
			
		||||
type NodeName = string;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Output for a DOM conversion.
 | 
			
		||||
 * Node can be set to 'ignore' to ignore the conversion and handling of the DOMNode
 | 
			
		||||
 * including all its children.
 | 
			
		||||
 */
 | 
			
		||||
export type DOMConversionOutput = {
 | 
			
		||||
  after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
 | 
			
		||||
  forChild?: DOMChildConversion;
 | 
			
		||||
  node: null | LexicalNode | Array<LexicalNode>;
 | 
			
		||||
  node: null | LexicalNode | Array<LexicalNode> | 'ignore';
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type DOMExportOutputMap = Map<
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list';
 | 
			
		|||
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  $getSelection,
 | 
			
		||||
  $isRangeSelection,
 | 
			
		||||
  createEditor,
 | 
			
		||||
  DecoratorNode,
 | 
			
		||||
| 
						 | 
				
			
			@ -29,14 +30,14 @@ import {
 | 
			
		|||
  TextNode,
 | 
			
		||||
} from 'lexical';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  CreateEditorArgs,
 | 
			
		||||
  HTMLConfig,
 | 
			
		||||
  LexicalNodeReplacement,
 | 
			
		||||
} from '../../LexicalEditor';
 | 
			
		||||
import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor';
 | 
			
		||||
import {resetRandomKey} from '../../LexicalUtils';
 | 
			
		||||
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
 | 
			
		||||
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
 | 
			
		||||
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
			
		||||
import {EditorUiContext} from "../../../../ui/framework/core";
 | 
			
		||||
import {EditorUIManager} from "../../../../ui/framework/manager";
 | 
			
		||||
import {turtle} from "@codemirror/legacy-modes/mode/turtle";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
type TestEnv = {
 | 
			
		||||
| 
						 | 
				
			
			@ -420,6 +421,7 @@ const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeR
 | 
			
		|||
  TableRowNode,
 | 
			
		||||
  AutoLinkNode,
 | 
			
		||||
  LinkNode,
 | 
			
		||||
  DetailsNode,
 | 
			
		||||
  TestElementNode,
 | 
			
		||||
  TestSegmentedNode,
 | 
			
		||||
  TestExcludeFromCopyElementNode,
 | 
			
		||||
| 
						 | 
				
			
			@ -451,6 +453,7 @@ export function createTestEditor(
 | 
			
		|||
    ...config,
 | 
			
		||||
    nodes: DEFAULT_NODES.concat(customNodes),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return editor;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -465,6 +468,48 @@ export function createTestHeadlessEditor(
 | 
			
		|||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createTestContext(): EditorUiContext {
 | 
			
		||||
 | 
			
		||||
  const container = document.createElement('div');
 | 
			
		||||
  document.body.appendChild(container);
 | 
			
		||||
 | 
			
		||||
  const scrollWrap = document.createElement('div');
 | 
			
		||||
  const editorDOM = document.createElement('div');
 | 
			
		||||
  editorDOM.setAttribute('contenteditable', 'true');
 | 
			
		||||
 | 
			
		||||
  scrollWrap.append(editorDOM);
 | 
			
		||||
  container.append(scrollWrap);
 | 
			
		||||
 | 
			
		||||
  const editor = createTestEditor({
 | 
			
		||||
    namespace: 'testing',
 | 
			
		||||
    theme: {},
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  editor.setRootElement(editorDOM);
 | 
			
		||||
 | 
			
		||||
  const context = {
 | 
			
		||||
    containerDOM: container,
 | 
			
		||||
    editor: editor,
 | 
			
		||||
    editorDOM: editorDOM,
 | 
			
		||||
    error(text: string | Error): void {
 | 
			
		||||
    },
 | 
			
		||||
    manager: new EditorUIManager(),
 | 
			
		||||
    options: {},
 | 
			
		||||
    scrollDOM: scrollWrap,
 | 
			
		||||
    translate(text: string): string {
 | 
			
		||||
      return "";
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  context.manager.setContext(context);
 | 
			
		||||
 | 
			
		||||
  return context;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function destroyFromContext(context: EditorUiContext) {
 | 
			
		||||
  context.containerDOM.remove();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $assertRangeSelection(selection: unknown): RangeSelection {
 | 
			
		||||
  if (!$isRangeSelection(selection)) {
 | 
			
		||||
    throw new Error(`Expected RangeSelection, got ${selection}`);
 | 
			
		||||
| 
						 | 
				
			
			@ -715,6 +760,61 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
 | 
			
		|||
  expect(formatHtml(expected)).toBe(formatHtml(actual));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type nodeTextShape = {
 | 
			
		||||
  text: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type nodeShape = {
 | 
			
		||||
  type: string;
 | 
			
		||||
  children?: (nodeShape|nodeTextShape)[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape {
 | 
			
		||||
  // @ts-ignore
 | 
			
		||||
  const children: SerializedLexicalNode[] = (node.children || []);
 | 
			
		||||
 | 
			
		||||
  const shape: nodeShape = {
 | 
			
		||||
    type: node.type,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (shape.type === 'text') {
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    return  {text: node.text}
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (children.length > 0) {
 | 
			
		||||
    shape.children = children.map(c => getNodeShape(c));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return shape;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) {
 | 
			
		||||
  const json = editor.getEditorState().toJSON();
 | 
			
		||||
  const shape = getNodeShape(json.root) as nodeShape;
 | 
			
		||||
  expect(shape.children).toMatchObject(expected);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function formatHtml(s: string): string {
 | 
			
		||||
  return s.replace(/>\s+</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 () => {
 | 
			
		||||
    expect(typeof window === 'undefined').toBe(true);
 | 
			
		||||
    expect(typeof document === 'undefined').toBe(true);
 | 
			
		||||
    expect(typeof navigator === 'undefined').toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('can update editor', async () => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -217,6 +217,11 @@ function $createNodesFromDOM(
 | 
			
		|||
  if (transformOutput !== null) {
 | 
			
		||||
    postTransform = transformOutput.after;
 | 
			
		||||
    const transformNodes = transformOutput.node;
 | 
			
		||||
 | 
			
		||||
    if (transformNodes === 'ignore') {
 | 
			
		||||
      return lexicalNodes;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    currentLexicalNode = Array.isArray(transformNodes)
 | 
			
		||||
      ? transformNodes[transformNodes.length - 1]
 | 
			
		||||
      : transformNodes;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -271,11 +271,18 @@ export class ListItemNode extends ElementNode {
 | 
			
		|||
  insertNewAfter(
 | 
			
		||||
    _: RangeSelection,
 | 
			
		||||
    restoreSelection = true,
 | 
			
		||||
  ): ListItemNode | ParagraphNode {
 | 
			
		||||
  ): ListItemNode | ParagraphNode | null {
 | 
			
		||||
 | 
			
		||||
    if (this.getTextContent().trim() === '' && this.isLastChild()) {
 | 
			
		||||
      const list = this.getParentOrThrow<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();
 | 
			
		||||
        list.insertAfter(paragraph, restoreSelection);
 | 
			
		||||
        this.remove();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| 
						 | 
				
			
			@ -5,18 +5,20 @@ import {
 | 
			
		|||
    LexicalEditor,
 | 
			
		||||
    LexicalNode,
 | 
			
		||||
    SerializedElementNode, Spread,
 | 
			
		||||
    EditorConfig,
 | 
			
		||||
    EditorConfig, DOMExportOutput,
 | 
			
		||||
} from 'lexical';
 | 
			
		||||
 | 
			
		||||
import {el} from "../../utils/dom";
 | 
			
		||||
import {extractDirectionFromElement} from "lexical/nodes/common";
 | 
			
		||||
 | 
			
		||||
export type SerializedDetailsNode = Spread<{
 | 
			
		||||
    id: string;
 | 
			
		||||
    summary: string;
 | 
			
		||||
}, SerializedElementNode>
 | 
			
		||||
 | 
			
		||||
export class DetailsNode extends ElementNode {
 | 
			
		||||
    __id: string = '';
 | 
			
		||||
    __summary: string = '';
 | 
			
		||||
    __open: boolean = false;
 | 
			
		||||
 | 
			
		||||
    static getType() {
 | 
			
		||||
        return 'details';
 | 
			
		||||
| 
						 | 
				
			
			@ -32,10 +34,32 @@ export class DetailsNode extends ElementNode {
 | 
			
		|||
        return self.__id;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setSummary(summary: string) {
 | 
			
		||||
        const self = this.getWritable();
 | 
			
		||||
        self.__summary = summary;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getSummary(): string {
 | 
			
		||||
        const self = this.getLatest();
 | 
			
		||||
        return self.__summary;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setOpen(open: boolean) {
 | 
			
		||||
        const self = this.getWritable();
 | 
			
		||||
        self.__open = open;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getOpen(): boolean {
 | 
			
		||||
        const self = this.getLatest();
 | 
			
		||||
        return self.__open;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static clone(node: DetailsNode): DetailsNode {
 | 
			
		||||
        const newNode =  new DetailsNode(node.__key);
 | 
			
		||||
        newNode.__id = node.__id;
 | 
			
		||||
        newNode.__dir = node.__dir;
 | 
			
		||||
        newNode.__summary = node.__summary;
 | 
			
		||||
        newNode.__open = node.__open;
 | 
			
		||||
        return newNode;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -49,12 +73,34 @@ export class DetailsNode extends ElementNode {
 | 
			
		|||
            el.setAttribute('dir', this.__dir);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.__open) {
 | 
			
		||||
            el.setAttribute('open', 'true');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const summary = document.createElement('summary');
 | 
			
		||||
        summary.textContent = this.__summary;
 | 
			
		||||
        summary.setAttribute('contenteditable', 'false');
 | 
			
		||||
        summary.addEventListener('click', event => {
 | 
			
		||||
            event.preventDefault();
 | 
			
		||||
            _editor.update(() => {
 | 
			
		||||
                this.select();
 | 
			
		||||
            })
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        el.append(summary);
 | 
			
		||||
 | 
			
		||||
        return el;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updateDOM(prevNode: DetailsNode, dom: HTMLElement) {
 | 
			
		||||
 | 
			
		||||
        if (prevNode.__open !== this.__open) {
 | 
			
		||||
            dom.toggleAttribute('open', this.__open);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return prevNode.__id !== this.__id
 | 
			
		||||
        || prevNode.__dir !== this.__dir;
 | 
			
		||||
        || prevNode.__dir !== this.__dir
 | 
			
		||||
        || prevNode.__summary !== this.__summary;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static importDOM(): DOMConversionMap|null {
 | 
			
		||||
| 
						 | 
				
			
			@ -71,20 +117,44 @@ export class DetailsNode extends ElementNode {
 | 
			
		|||
                            node.setDirection(extractDirectionFromElement(element));
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        const summaryElem = Array.from(element.children).find(e => e.nodeName === 'SUMMARY');
 | 
			
		||||
                        node.setSummary(summaryElem?.textContent || '');
 | 
			
		||||
 | 
			
		||||
                        return {node};
 | 
			
		||||
                    },
 | 
			
		||||
                    priority: 3,
 | 
			
		||||
                };
 | 
			
		||||
            },
 | 
			
		||||
            summary(node: HTMLElement): DOMConversion|null {
 | 
			
		||||
                return {
 | 
			
		||||
                    conversion: (element: HTMLElement): DOMConversionOutput|null => {
 | 
			
		||||
                        return {node: 'ignore'};
 | 
			
		||||
                    },
 | 
			
		||||
                    priority: 3,
 | 
			
		||||
                };
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    exportDOM(editor: LexicalEditor): DOMExportOutput {
 | 
			
		||||
        const element = this.createDOM(editor._config, editor);
 | 
			
		||||
        const editable = element.querySelectorAll('[contenteditable]');
 | 
			
		||||
        for (const elem of editable) {
 | 
			
		||||
            elem.removeAttribute('contenteditable');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        element.removeAttribute('open');
 | 
			
		||||
 | 
			
		||||
        return {element};
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    exportJSON(): SerializedDetailsNode {
 | 
			
		||||
        return {
 | 
			
		||||
            ...super.exportJSON(),
 | 
			
		||||
            type: 'details',
 | 
			
		||||
            version: 1,
 | 
			
		||||
            id: this.__id,
 | 
			
		||||
            summary: this.__summary,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -104,58 +174,3 @@ export function $createDetailsNode() {
 | 
			
		|||
export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode {
 | 
			
		||||
    return node instanceof DetailsNode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class SummaryNode extends ElementNode {
 | 
			
		||||
 | 
			
		||||
    static getType() {
 | 
			
		||||
        return 'summary';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static clone(node: SummaryNode) {
 | 
			
		||||
        return new SummaryNode(node.__key);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    createDOM(_config: EditorConfig, _editor: LexicalEditor) {
 | 
			
		||||
        return el('summary');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updateDOM(prevNode: DetailsNode, dom: HTMLElement) {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static importDOM(): DOMConversionMap|null {
 | 
			
		||||
        return {
 | 
			
		||||
            summary(node: HTMLElement): DOMConversion|null {
 | 
			
		||||
                return {
 | 
			
		||||
                    conversion: (element: HTMLElement): DOMConversionOutput|null => {
 | 
			
		||||
                        return {
 | 
			
		||||
                            node: new SummaryNode(),
 | 
			
		||||
                        };
 | 
			
		||||
                    },
 | 
			
		||||
                    priority: 3,
 | 
			
		||||
                };
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    exportJSON(): SerializedElementNode {
 | 
			
		||||
        return {
 | 
			
		||||
            ...super.exportJSON(),
 | 
			
		||||
            type: 'summary',
 | 
			
		||||
            version: 1,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static importJSON(serializedNode: SerializedElementNode): SummaryNode {
 | 
			
		||||
        return $createSummaryNode();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $createSummaryNode(): SummaryNode {
 | 
			
		||||
    return new SummaryNode();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $isSummaryNode(node: LexicalNode | null | undefined): node is SummaryNode {
 | 
			
		||||
    return node instanceof SummaryNode;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
import {LinkNode} from "@lexical/link";
 | 
			
		||||
import {ImageNode} from "@lexical/rich-text/LexicalImageNode";
 | 
			
		||||
import {DetailsNode, SummaryNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
			
		||||
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
			
		||||
import {ListItemNode, ListNode} from "@lexical/list";
 | 
			
		||||
import {TableCellNode, TableNode, TableRowNode} from "@lexical/table";
 | 
			
		||||
import {HorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode";
 | 
			
		||||
| 
						 | 
				
			
			@ -34,7 +34,7 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
 | 
			
		|||
        TableCellNode,
 | 
			
		||||
        ImageNode, // TODO - Alignment
 | 
			
		||||
        HorizontalRuleNode,
 | 
			
		||||
        DetailsNode, SummaryNode,
 | 
			
		||||
        DetailsNode,
 | 
			
		||||
        CodeBlockNode,
 | 
			
		||||
        DiagramNode,
 | 
			
		||||
        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 {
 | 
			
		||||
    appendHtmlToEditor,
 | 
			
		||||
    focusEditor,
 | 
			
		||||
| 
						 | 
				
			
			@ -40,4 +40,16 @@ export function listen(editor: LexicalEditor): void {
 | 
			
		|||
    window.$events.listen<EditorEventContent>('editor::focus', () => {
 | 
			
		||||
        focusEditor(editor);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    let changeFromLoading = true;
 | 
			
		||||
    editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
 | 
			
		||||
        // Emit change event to component system (for draft detection) on actual user content change
 | 
			
		||||
        if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
 | 
			
		||||
            if (changeFromLoading) {
 | 
			
		||||
                changeFromLoading = false;
 | 
			
		||||
            } else {
 | 
			
		||||
                window.$events.emit('editor-html-change', '');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,7 @@ import {
 | 
			
		|||
    $createParagraphNode,
 | 
			
		||||
    $getSelection,
 | 
			
		||||
    $isDecoratorNode,
 | 
			
		||||
    COMMAND_PRIORITY_LOW,
 | 
			
		||||
    COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND,
 | 
			
		||||
    KEY_BACKSPACE_COMMAND,
 | 
			
		||||
    KEY_DELETE_COMMAND,
 | 
			
		||||
    KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
 | 
			
		||||
| 
						 | 
				
			
			@ -13,9 +13,10 @@ import {
 | 
			
		|||
import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
 | 
			
		||||
import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
 | 
			
		||||
import {getLastSelection} from "../utils/selection";
 | 
			
		||||
import {$getNearestNodeBlockParent} from "../utils/nodes";
 | 
			
		||||
import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes";
 | 
			
		||||
import {$setInsetForSelection} from "../utils/lists";
 | 
			
		||||
import {$isListItemNode} from "@lexical/list";
 | 
			
		||||
import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
			
		||||
 | 
			
		||||
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
 | 
			
		||||
    if (nodes.length === 1) {
 | 
			
		||||
| 
						 | 
				
			
			@ -28,6 +29,10 @@ function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
 | 
			
		|||
    return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Delete the current node in the selection if the selection contains a single
 | 
			
		||||
 * selected node (like image, media etc...).
 | 
			
		||||
 */
 | 
			
		||||
function deleteSingleSelectedNode(editor: LexicalEditor) {
 | 
			
		||||
    const selectionNodes = getLastSelection(editor)?.getNodes() || [];
 | 
			
		||||
    if (isSingleSelectedNode(selectionNodes)) {
 | 
			
		||||
| 
						 | 
				
			
			@ -37,6 +42,10 @@ function deleteSingleSelectedNode(editor: LexicalEditor) {
 | 
			
		|||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Insert a new empty node after the selection if the selection contains a single
 | 
			
		||||
 * selected node (like image, media etc...).
 | 
			
		||||
 */
 | 
			
		||||
function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
 | 
			
		||||
    const selectionNodes = getLastSelection(editor)?.getNodes() || [];
 | 
			
		||||
    if (isSingleSelectedNode(selectionNodes)) {
 | 
			
		||||
| 
						 | 
				
			
			@ -58,11 +67,108 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve
 | 
			
		|||
    return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Insert a new node after a details node, if inside a details node that's
 | 
			
		||||
 * the last element, and if the cursor is at the last block within the details node.
 | 
			
		||||
 */
 | 
			
		||||
function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
 | 
			
		||||
    const scenario = getDetailsScenario(editor);
 | 
			
		||||
    if (scenario === null || scenario.detailsSibling) {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    editor.update(() => {
 | 
			
		||||
        const newParagraph = $createParagraphNode();
 | 
			
		||||
        scenario.parentDetails.insertAfter(newParagraph);
 | 
			
		||||
        newParagraph.select();
 | 
			
		||||
    });
 | 
			
		||||
    event?.preventDefault();
 | 
			
		||||
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * If within a details block, move after it, creating a new node if required, if we're on
 | 
			
		||||
 * the last empty block element within the details node.
 | 
			
		||||
 */
 | 
			
		||||
function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
 | 
			
		||||
    const scenario = getDetailsScenario(editor);
 | 
			
		||||
    if (scenario === null) {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (scenario.parentBlock.getTextContent() !== '') {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    event?.preventDefault()
 | 
			
		||||
 | 
			
		||||
    const nextSibling = scenario.parentDetails.getNextSibling();
 | 
			
		||||
    editor.update(() => {
 | 
			
		||||
        if (nextSibling) {
 | 
			
		||||
            nextSibling.selectStart();
 | 
			
		||||
        } else {
 | 
			
		||||
            const newParagraph = $createParagraphNode();
 | 
			
		||||
            scenario.parentDetails.insertAfter(newParagraph);
 | 
			
		||||
            newParagraph.select();
 | 
			
		||||
        }
 | 
			
		||||
        scenario.parentBlock.remove();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get the common nodes used for a details node scenario, relative to current selection.
 | 
			
		||||
 * Returns null if not found, or if the parent block is not the last in the parent details node.
 | 
			
		||||
 */
 | 
			
		||||
function getDetailsScenario(editor: LexicalEditor): {
 | 
			
		||||
    parentDetails: DetailsNode;
 | 
			
		||||
    parentBlock: LexicalNode;
 | 
			
		||||
    detailsSibling: LexicalNode | null
 | 
			
		||||
} | null {
 | 
			
		||||
    const selection = getLastSelection(editor);
 | 
			
		||||
    const firstNode = selection?.getNodes()[0];
 | 
			
		||||
    if (!firstNode) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const block = $getNearestNodeBlockParent(firstNode);
 | 
			
		||||
    const details = $getParentOfType(firstNode, $isDetailsNode);
 | 
			
		||||
    if (!$isDetailsNode(details) || block === null) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (block.getKey() !== details.getLastChild()?.getKey()) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nextSibling = details.getNextSibling();
 | 
			
		||||
    return {
 | 
			
		||||
        parentDetails: details,
 | 
			
		||||
        parentBlock: block,
 | 
			
		||||
        detailsSibling: nextSibling,
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function $isSingleListItem(nodes: LexicalNode[]): boolean {
 | 
			
		||||
    if (nodes.length !== 1) {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const node = nodes[0];
 | 
			
		||||
    return $isListItemNode(node) || $isListItemNode(node.getParent());
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Inset the nodes within selection when a range of nodes is selected
 | 
			
		||||
 * or if a list node is selected.
 | 
			
		||||
 */
 | 
			
		||||
function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
 | 
			
		||||
    const change = event?.shiftKey ? -40 : 40;
 | 
			
		||||
    const selection = $getSelection();
 | 
			
		||||
    const nodes = selection?.getNodes() || [];
 | 
			
		||||
    if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) {
 | 
			
		||||
    if (nodes.length > 1 || $isSingleListItem(nodes)) {
 | 
			
		||||
        editor.update(() => {
 | 
			
		||||
            $setInsetForSelection(editor, change);
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			@ -85,17 +191,23 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
 | 
			
		|||
    }, COMMAND_PRIORITY_LOW);
 | 
			
		||||
 | 
			
		||||
    const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
 | 
			
		||||
        return insertAfterSingleSelectedNode(context.editor, event);
 | 
			
		||||
        return insertAfterSingleSelectedNode(context.editor, event)
 | 
			
		||||
            || moveAfterDetailsOnEmptyLine(context.editor, event);
 | 
			
		||||
    }, COMMAND_PRIORITY_LOW);
 | 
			
		||||
 | 
			
		||||
    const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
 | 
			
		||||
        return handleInsetOnTab(context.editor, event);
 | 
			
		||||
    }, COMMAND_PRIORITY_LOW);
 | 
			
		||||
 | 
			
		||||
    const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => {
 | 
			
		||||
        return insertAfterDetails(context.editor, event);
 | 
			
		||||
    }, COMMAND_PRIORITY_LOW);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
        unregisterBackspace();
 | 
			
		||||
        unregisterDelete();
 | 
			
		||||
        unregisterEnter();
 | 
			
		||||
        unregisterTab();
 | 
			
		||||
        unregisterDown();
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -11,8 +11,9 @@ import {
 | 
			
		|||
} from "lexical";
 | 
			
		||||
import redoIcon from "@icons/editor/redo.svg";
 | 
			
		||||
import sourceIcon from "@icons/editor/source-view.svg";
 | 
			
		||||
import {getEditorContentAsHtml} from "../../../utils/actions";
 | 
			
		||||
import fullscreenIcon from "@icons/editor/fullscreen.svg";
 | 
			
		||||
import aboutIcon from "@icons/editor/about.svg";
 | 
			
		||||
import {getEditorContentAsHtml} from "../../../utils/actions";
 | 
			
		||||
 | 
			
		||||
export const undo: EditorButtonDefinition = {
 | 
			
		||||
    label: 'Undo',
 | 
			
		||||
| 
						 | 
				
			
			@ -81,3 +82,15 @@ export const fullscreen: EditorButtonDefinition = {
 | 
			
		|||
        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 {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
 | 
			
		||||
import detailsIcon from "@icons/editor/details.svg";
 | 
			
		||||
import detailsToggleIcon from "@icons/editor/details-toggle.svg";
 | 
			
		||||
import tableDeleteIcon from "@icons/editor/table-delete.svg";
 | 
			
		||||
import tagIcon from "@icons/tag.svg";
 | 
			
		||||
import mediaIcon from "@icons/editor/media.svg";
 | 
			
		||||
import {$createDetailsNode, $isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
			
		||||
import {$isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode";
 | 
			
		||||
| 
						 | 
				
			
			@ -29,7 +32,7 @@ import {
 | 
			
		|||
} from "../../../utils/selection";
 | 
			
		||||
import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams";
 | 
			
		||||
import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
 | 
			
		||||
import {$showImageForm, $showLinkForm} from "../forms/objects";
 | 
			
		||||
import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects";
 | 
			
		||||
import {formatCodeBlock} from "../../../utils/formats";
 | 
			
		||||
 | 
			
		||||
export const link: EditorButtonDefinition = {
 | 
			
		||||
| 
						 | 
				
			
			@ -217,3 +220,57 @@ export const details: EditorButtonDefinition = {
 | 
			
		|||
        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 {EditorUiContext} from "../../framework/core";
 | 
			
		||||
import {EditorUiContext, EditorUiElement} from "../../framework/core";
 | 
			
		||||
import {setEditorContentFromHtml} from "../../../utils/actions";
 | 
			
		||||
import {ExternalContent} from "../../framework/blocks/external-content";
 | 
			
		||||
 | 
			
		||||
export const source: EditorFormDefinition = {
 | 
			
		||||
    submitText: 'Save',
 | 
			
		||||
| 
						 | 
				
			
			@ -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 {LinkField} from "../../framework/blocks/link-field";
 | 
			
		||||
import {insertOrUpdateLink} from "../../../utils/formats";
 | 
			
		||||
import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 | 
			
		||||
 | 
			
		||||
export function $showImageForm(image: ImageNode, context: EditorUiContext) {
 | 
			
		||||
    const imageModal: EditorFormModal = context.manager.createModal('image');
 | 
			
		||||
| 
						 | 
				
			
			@ -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 {image, link, media} from "./forms/objects";
 | 
			
		||||
import {source} from "./forms/controls";
 | 
			
		||||
import {details, image, link, media} from "./forms/objects";
 | 
			
		||||
import {about, source} from "./forms/controls";
 | 
			
		||||
import {cellProperties, rowProperties, tableProperties} from "./forms/tables";
 | 
			
		||||
 | 
			
		||||
export const modals: Record<string, EditorFormModalDefinition> = {
 | 
			
		||||
| 
						 | 
				
			
			@ -32,4 +32,12 @@ export const modals: Record<string, EditorFormModalDefinition> = {
 | 
			
		|||
        title: 'Table Properties',
 | 
			
		||||
        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 {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core";
 | 
			
		||||
import {EditorFormatMenu} from "./framework/blocks/format-menu";
 | 
			
		||||
import {FormatPreviewButton} from "./framework/blocks/format-preview-button";
 | 
			
		||||
import {EditorDropdownButton} from "./framework/blocks/dropdown-button";
 | 
			
		||||
import {EditorColorPicker} from "./framework/blocks/color-picker";
 | 
			
		||||
import {EditorTableCreator} from "./framework/blocks/table-creator";
 | 
			
		||||
import {EditorColorButton} from "./framework/blocks/color-button";
 | 
			
		||||
import {EditorOverflowContainer} from "./framework/blocks/overflow-container";
 | 
			
		||||
import {EditorButton} from "../framework/buttons";
 | 
			
		||||
import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "../framework/core";
 | 
			
		||||
import {EditorFormatMenu} from "../framework/blocks/format-menu";
 | 
			
		||||
import {FormatPreviewButton} from "../framework/blocks/format-preview-button";
 | 
			
		||||
import {EditorDropdownButton} from "../framework/blocks/dropdown-button";
 | 
			
		||||
import {EditorColorPicker} from "../framework/blocks/color-picker";
 | 
			
		||||
import {EditorTableCreator} from "../framework/blocks/table-creator";
 | 
			
		||||
import {EditorColorButton} from "../framework/blocks/color-button";
 | 
			
		||||
import {EditorOverflowContainer} from "../framework/blocks/overflow-container";
 | 
			
		||||
import {
 | 
			
		||||
    cellProperties, clearTableFormatting,
 | 
			
		||||
    copyColumn,
 | 
			
		||||
| 
						 | 
				
			
			@ -29,8 +29,8 @@ import {
 | 
			
		|||
    rowProperties,
 | 
			
		||||
    splitCell,
 | 
			
		||||
    table, tableProperties
 | 
			
		||||
} from "./defaults/buttons/tables";
 | 
			
		||||
import {fullscreen, redo, source, undo} from "./defaults/buttons/controls";
 | 
			
		||||
} from "./buttons/tables";
 | 
			
		||||
import {about, fullscreen, redo, source, undo} from "./buttons/controls";
 | 
			
		||||
import {
 | 
			
		||||
    blockquote, dangerCallout,
 | 
			
		||||
    h2,
 | 
			
		||||
| 
						 | 
				
			
			@ -41,7 +41,7 @@ import {
 | 
			
		|||
    paragraph,
 | 
			
		||||
    successCallout,
 | 
			
		||||
    warningCallout
 | 
			
		||||
} from "./defaults/buttons/block-formats";
 | 
			
		||||
} from "./buttons/block-formats";
 | 
			
		||||
import {
 | 
			
		||||
    bold, clearFormating, code,
 | 
			
		||||
    highlightColor,
 | 
			
		||||
| 
						 | 
				
			
			@ -50,7 +50,7 @@ import {
 | 
			
		|||
    superscript,
 | 
			
		||||
    textColor,
 | 
			
		||||
    underline
 | 
			
		||||
} from "./defaults/buttons/inline-formats";
 | 
			
		||||
} from "./buttons/inline-formats";
 | 
			
		||||
import {
 | 
			
		||||
    alignCenter,
 | 
			
		||||
    alignJustify,
 | 
			
		||||
| 
						 | 
				
			
			@ -58,27 +58,27 @@ import {
 | 
			
		|||
    alignRight,
 | 
			
		||||
    directionLTR,
 | 
			
		||||
    directionRTL
 | 
			
		||||
} from "./defaults/buttons/alignments";
 | 
			
		||||
} from "./buttons/alignments";
 | 
			
		||||
import {
 | 
			
		||||
    bulletList,
 | 
			
		||||
    indentDecrease,
 | 
			
		||||
    indentIncrease,
 | 
			
		||||
    numberList,
 | 
			
		||||
    taskList
 | 
			
		||||
} from "./defaults/buttons/lists";
 | 
			
		||||
} from "./buttons/lists";
 | 
			
		||||
import {
 | 
			
		||||
    codeBlock,
 | 
			
		||||
    details,
 | 
			
		||||
    details, detailsEditLabel, detailsToggle, detailsUnwrap,
 | 
			
		||||
    diagram, diagramManager,
 | 
			
		||||
    editCodeBlock,
 | 
			
		||||
    horizontalRule,
 | 
			
		||||
    image,
 | 
			
		||||
    link, media,
 | 
			
		||||
    unlink
 | 
			
		||||
} from "./defaults/buttons/objects";
 | 
			
		||||
import {el} from "../utils/dom";
 | 
			
		||||
import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu";
 | 
			
		||||
import {EditorSeparator} from "./framework/blocks/separator";
 | 
			
		||||
} from "./buttons/objects";
 | 
			
		||||
import {el} from "../../utils/dom";
 | 
			
		||||
import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu";
 | 
			
		||||
import {EditorSeparator} from "../framework/blocks/separator";
 | 
			
		||||
 | 
			
		||||
export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement {
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -149,8 +149,8 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
 | 
			
		|||
        new EditorOverflowContainer(4, [
 | 
			
		||||
            new EditorButton(link),
 | 
			
		||||
 | 
			
		||||
            new EditorDropdownButton({button: table, direction: 'vertical'}, [
 | 
			
		||||
                new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true}, [
 | 
			
		||||
            new EditorDropdownButton({button: table, direction: 'vertical', showAside: false}, [
 | 
			
		||||
                new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true, showAside: true}, [
 | 
			
		||||
                    new EditorTableCreator(),
 | 
			
		||||
                ]),
 | 
			
		||||
                new EditorSeparator(),
 | 
			
		||||
| 
						 | 
				
			
			@ -201,6 +201,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
 | 
			
		|||
        // Meta elements
 | 
			
		||||
        new EditorOverflowContainer(3, [
 | 
			
		||||
            new EditorButton(source),
 | 
			
		||||
            new EditorButton(about),
 | 
			
		||||
            new EditorButton(fullscreen),
 | 
			
		||||
 | 
			
		||||
            // Test
 | 
			
		||||
| 
						 | 
				
			
			@ -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},
 | 
			
		||||
            showOnHover: false,
 | 
			
		||||
            direction: 'vertical',
 | 
			
		||||
            showAside: false,
 | 
			
		||||
        }, menuItems);
 | 
			
		||||
        this.addChildren(this.dropdownButton);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,12 +7,14 @@ import {EditorMenuButton} from "./menu-button";
 | 
			
		|||
export type EditorDropdownButtonOptions = {
 | 
			
		||||
    showOnHover?: boolean;
 | 
			
		||||
    direction?: 'vertical'|'horizontal';
 | 
			
		||||
    showAside?: boolean;
 | 
			
		||||
    button: EditorBasicButtonDefinition|EditorButton;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const defaultOptions: EditorDropdownButtonOptions = {
 | 
			
		||||
    showOnHover: false,
 | 
			
		||||
    direction: 'horizontal',
 | 
			
		||||
    showAside: undefined,
 | 
			
		||||
    button: {label: 'Menu'},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -65,6 +67,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
 | 
			
		|||
 | 
			
		||||
        handleDropdown({toggle: button, menu : menu,
 | 
			
		||||
            showOnHover: this.options.showOnHover,
 | 
			
		||||
            showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'),
 | 
			
		||||
            onOpen : () => {
 | 
			
		||||
            this.open = true;
 | 
			
		||||
            this.getContext().manager.triggerStateUpdateForElement(this.button);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
    toggle: HTMLElement;
 | 
			
		||||
    menu: HTMLElement;
 | 
			
		||||
    showOnHover?: boolean,
 | 
			
		||||
    onOpen?: Function | undefined;
 | 
			
		||||
    onClose?: Function | undefined;
 | 
			
		||||
    showAside?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean) {
 | 
			
		||||
    const toggleRect = toggle.getBoundingClientRect();
 | 
			
		||||
    const menuBounds = menu.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
    menu.style.position = 'fixed';
 | 
			
		||||
 | 
			
		||||
    if (showAside) {
 | 
			
		||||
        let targetLeft = toggleRect.right;
 | 
			
		||||
        const isRightOOB = toggleRect.right + menuBounds.width > window.innerWidth;
 | 
			
		||||
        if (isRightOOB) {
 | 
			
		||||
            targetLeft = Math.max(toggleRect.left - menuBounds.width, 0);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        menu.style.top = toggleRect.top + 'px';
 | 
			
		||||
        menu.style.left = targetLeft + 'px';
 | 
			
		||||
    } else {
 | 
			
		||||
        const isRightOOB = toggleRect.left + menuBounds.width > window.innerWidth;
 | 
			
		||||
        let targetLeft = toggleRect.left;
 | 
			
		||||
        if (isRightOOB) {
 | 
			
		||||
            targetLeft = Math.max(toggleRect.right - menuBounds.width, 0);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        menu.style.top = toggleRect.bottom + 'px';
 | 
			
		||||
        menu.style.left = targetLeft + 'px';
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function handleDropdown(options: HandleDropdownParams) {
 | 
			
		||||
    const {menu, toggle, onClose, onOpen, showOnHover} = options;
 | 
			
		||||
    const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options;
 | 
			
		||||
    let clickListener: Function|null = null;
 | 
			
		||||
 | 
			
		||||
    const hide = () => {
 | 
			
		||||
        menu.hidden = true;
 | 
			
		||||
        menu.style.removeProperty('position');
 | 
			
		||||
        menu.style.removeProperty('left');
 | 
			
		||||
        menu.style.removeProperty('top');
 | 
			
		||||
        if (clickListener) {
 | 
			
		||||
            window.removeEventListener('click', clickListener as EventListener);
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -25,6 +53,7 @@ export function handleDropdown(options: HandleDropdownParams) {
 | 
			
		|||
 | 
			
		||||
    const show = () => {
 | 
			
		||||
        menu.hidden = false
 | 
			
		||||
        positionMenu(menu, toggle, Boolean(showAside));
 | 
			
		||||
        clickListener = (event: MouseEvent) => {
 | 
			
		||||
            if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) {
 | 
			
		||||
                hide();
 | 
			
		||||
| 
						 | 
				
			
			@ -44,5 +73,18 @@ export function handleDropdown(options: HandleDropdownParams) {
 | 
			
		|||
        toggle.addEventListener('mouseenter', toggleShowing);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    menu.parentElement?.addEventListener('mouseleave', hide);
 | 
			
		||||
    menu.parentElement?.addEventListener('mouseleave', (event: MouseEvent) => {
 | 
			
		||||
 | 
			
		||||
        // Prevent mouseleave hiding if withing the same bounds of the toggle.
 | 
			
		||||
        // Avoids hiding in the event the mouse is interrupted by a high z-index
 | 
			
		||||
        // item like a browser scrollbar.
 | 
			
		||||
        const toggleBounds = toggle.getBoundingClientRect();
 | 
			
		||||
        const withinX = event.clientX <= toggleBounds.right && event.clientX >= toggleBounds.left;
 | 
			
		||||
        const withinY = event.clientY <= toggleBounds.bottom && event.clientY >= toggleBounds.top;
 | 
			
		||||
        const withinToggle = withinX && withinY;
 | 
			
		||||
 | 
			
		||||
        if (!withinToggle) {
 | 
			
		||||
            hide();
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import {EditorFormModal, EditorFormModalDefinition} from "./modals";
 | 
			
		||||
import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
 | 
			
		||||
import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
 | 
			
		||||
import {BaseSelection, LexicalEditor} from "lexical";
 | 
			
		||||
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
 | 
			
		||||
import {DecoratorListener} from "lexical/LexicalEditor";
 | 
			
		||||
import type {NodeKey} from "lexical/LexicalNode";
 | 
			
		||||
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
 | 
			
		||||
| 
						 | 
				
			
			@ -231,6 +231,22 @@ export class EditorUIManager {
 | 
			
		|||
            });
 | 
			
		||||
        }
 | 
			
		||||
        editor.registerDecoratorListener(domDecorateListener);
 | 
			
		||||
 | 
			
		||||
        // Watch for changes to update local state
 | 
			
		||||
        editor.registerUpdateListener(({editorState, prevEditorState}) => {
 | 
			
		||||
            // Watch for selection changes to update the UI on change
 | 
			
		||||
            // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
 | 
			
		||||
            // for all selection changes, so this proved more reliable.
 | 
			
		||||
            const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
 | 
			
		||||
            if (selectionChange) {
 | 
			
		||||
                editor.update(() => {
 | 
			
		||||
                    const selection = $getSelection();
 | 
			
		||||
                    this.triggerStateUpdate({
 | 
			
		||||
                        editor, selection,
 | 
			
		||||
                    });
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected setupEventListeners(context: EditorUiContext) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,10 @@
 | 
			
		|||
import {LexicalEditor} from "lexical";
 | 
			
		||||
import {
 | 
			
		||||
    getCodeToolbarContent,
 | 
			
		||||
    getCodeToolbarContent, getDetailsToolbarContent,
 | 
			
		||||
    getImageToolbarContent,
 | 
			
		||||
    getLinkToolbarContent,
 | 
			
		||||
    getMainEditorFullToolbar, getTableToolbarContent
 | 
			
		||||
} from "./toolbars";
 | 
			
		||||
} from "./defaults/toolbars";
 | 
			
		||||
import {EditorUIManager} from "./framework/manager";
 | 
			
		||||
import {EditorUiContext} from "./framework/core";
 | 
			
		||||
import {CodeBlockDecorator} from "./decorators/code-block";
 | 
			
		||||
| 
						 | 
				
			
			@ -56,7 +56,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
 | 
			
		|||
        selector: '.editor-code-block-wrap',
 | 
			
		||||
        content: getCodeToolbarContent(),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    manager.registerContextToolbar('table', {
 | 
			
		||||
        selector: 'td,th',
 | 
			
		||||
        content: getTableToolbarContent(),
 | 
			
		||||
| 
						 | 
				
			
			@ -64,6 +63,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
 | 
			
		|||
            return originalTarget.closest('table') as HTMLTableElement;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    manager.registerContextToolbar('details', {
 | 
			
		||||
        selector: 'details',
 | 
			
		||||
        content: getDetailsToolbarContent(),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Register image decorator listener
 | 
			
		||||
    manager.registerDecoratorType('code', CodeBlockDecorator);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {nodeHasInset} from "./nodes";
 | 
			
		||||
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
 | 
			
		||||
| 
						 | 
				
			
			@ -10,6 +10,9 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
 | 
			
		|||
        return node;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null;
 | 
			
		||||
    const nodeChildItems = nodeChildList?.getChildren() || [];
 | 
			
		||||
 | 
			
		||||
    const listItems = list.getChildren() as ListItemNode[];
 | 
			
		||||
    const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
 | 
			
		||||
    const isFirst = nodeIndex === 0;
 | 
			
		||||
| 
						 | 
				
			
			@ -27,6 +30,13 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
 | 
			
		|||
        node.remove();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (nodeChildList) {
 | 
			
		||||
        for (const child of nodeChildItems) {
 | 
			
		||||
            newListItem.insertAfter(child);
 | 
			
		||||
        }
 | 
			
		||||
        nodeChildList.remove();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return newListItem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -38,6 +48,8 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
 | 
			
		|||
        return node;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const laterSiblings = node.getNextSiblings();
 | 
			
		||||
 | 
			
		||||
    parentListItem.insertAfter(node);
 | 
			
		||||
    if (list.getChildren().length === 0) {
 | 
			
		||||
        list.remove();
 | 
			
		||||
| 
						 | 
				
			
			@ -47,6 +59,16 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
 | 
			
		|||
        parentListItem.remove();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (laterSiblings.length > 0) {
 | 
			
		||||
        const childList = $createListNode(list.getListType());
 | 
			
		||||
        childList.append(...laterSiblings);
 | 
			
		||||
        node.append(childList);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (list.getChildrenSize() === 0) {
 | 
			
		||||
        list.remove();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return node;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -93,6 +115,7 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[
 | 
			
		|||
 | 
			
		||||
export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
 | 
			
		||||
    const selection = $getSelection();
 | 
			
		||||
    const selectionBounds = selection?.getStartEndPoints();
 | 
			
		||||
    const listItemsInSelection = getListItemsForSelection(selection);
 | 
			
		||||
    const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -110,7 +133,19 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo
 | 
			
		|||
            alteredListItems.reverse();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $selectNodes(alteredListItems);
 | 
			
		||||
        if (alteredListItems.length === 1 && selectionBounds) {
 | 
			
		||||
            // Retain selection range if moving just one item
 | 
			
		||||
            const listItem = alteredListItems[0] as ListItemNode;
 | 
			
		||||
            let child = listItem.getChildren()[0] as TextNode;
 | 
			
		||||
            if (!child) {
 | 
			
		||||
                child = $createTextNode('');
 | 
			
		||||
                listItem.append(child);
 | 
			
		||||
            }
 | 
			
		||||
            child.select(selectionBounds[0].offset, selectionBounds[1].offset);
 | 
			
		||||
        } else {
 | 
			
		||||
            $selectNodes(alteredListItems);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -51,6 +51,10 @@ export function $getNodeFromSelection(selection: BaseSelection | null, matcher:
 | 
			
		|||
    return null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $getTextNodeFromSelection(selection: BaseSelection | null): TextNode|null {
 | 
			
		||||
    return $getNodeFromSelection(selection, $isTextNode) as TextNode|null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean {
 | 
			
		||||
    if (!selection) {
 | 
			
		||||
        return false;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,18 +8,28 @@
 | 
			
		|||
 | 
			
		||||
// Main UI elements
 | 
			
		||||
.editor-container {
 | 
			
		||||
  background-color: #FFF;
 | 
			
		||||
  @include mixins.lightDark(background-color, #FFF, #222);
 | 
			
		||||
  position: relative;
 | 
			
		||||
  &.fullscreen {
 | 
			
		||||
    z-index: 500;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.editor-toolbar-main {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  border-top: 1px solid #DDD;
 | 
			
		||||
  border-bottom: 1px solid #DDD;
 | 
			
		||||
  @include mixins.lightDark(border-color, #DDD, #000);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@include mixins.smaller-than(vars.$bp-xl) {
 | 
			
		||||
  .editor-toolbar-main {
 | 
			
		||||
    overflow-x: scroll;
 | 
			
		||||
    flex-wrap: nowrap;
 | 
			
		||||
    justify-content: start;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body.editor-is-fullscreen {
 | 
			
		||||
| 
						 | 
				
			
			@ -38,6 +48,7 @@ body.editor-is-fullscreen {
 | 
			
		|||
.editor-content-wrap {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
  padding-inline: vars.$s;
 | 
			
		||||
  flex: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -46,6 +57,7 @@ body.editor-is-fullscreen {
 | 
			
		|||
  font-size: 12px;
 | 
			
		||||
  padding: 4px;
 | 
			
		||||
  color: #444;
 | 
			
		||||
  @include mixins.lightDark(color, #444, #999);
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
| 
						 | 
				
			
			@ -54,6 +66,7 @@ body.editor-is-fullscreen {
 | 
			
		|||
}
 | 
			
		||||
.editor-button:hover {
 | 
			
		||||
  background-color: #EEE;
 | 
			
		||||
  @include mixins.lightDark(background-color, #EEE, #333);
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  color: #000;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -63,7 +76,7 @@ body.editor-is-fullscreen {
 | 
			
		|||
  opacity: .6;
 | 
			
		||||
}
 | 
			
		||||
.editor-button-active, .editor-button-active:hover {
 | 
			
		||||
  background-color: #ceebff;
 | 
			
		||||
  @include mixins.lightDark(background-color, #ceebff, #444);
 | 
			
		||||
  color: #000;
 | 
			
		||||
}
 | 
			
		||||
.editor-button-long {
 | 
			
		||||
| 
						 | 
				
			
			@ -75,7 +88,7 @@ body.editor-is-fullscreen {
 | 
			
		|||
}
 | 
			
		||||
.editor-button-text {
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  color: #000;
 | 
			
		||||
  @include mixins.lightDark(color, #000, #AAA);
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  flex: 1;
 | 
			
		||||
  padding-inline-end: 4px;
 | 
			
		||||
| 
						 | 
				
			
			@ -126,7 +139,8 @@ body.editor-is-fullscreen {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
  &:hover {
 | 
			
		||||
    outline: 1px solid #DDD;
 | 
			
		||||
    outline: 1px solid;
 | 
			
		||||
    @include mixins.lightDark(outline-color, #DDD, #111);
 | 
			
		||||
    outline-offset: -3px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -137,11 +151,14 @@ body.editor-is-fullscreen {
 | 
			
		|||
}
 | 
			
		||||
.editor-dropdown-menu {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  background-color: #FFF;
 | 
			
		||||
  box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15);
 | 
			
		||||
  border: 1px solid;
 | 
			
		||||
  @include mixins.lightDark(background-color, #FFF, #292929);
 | 
			
		||||
  @include mixins.lightDark(border-color, #FFF, #333);
 | 
			
		||||
  @include mixins.lightDark(box-shadow, 0 0 6px 0 rgba(0, 0, 0, 0.15), 0 1px 4px 0 rgba(0, 0, 0, 0.4));
 | 
			
		||||
  z-index: 99;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  border-radius: 3px;
 | 
			
		||||
}
 | 
			
		||||
.editor-dropdown-menu-vertical {
 | 
			
		||||
  display: flex;
 | 
			
		||||
| 
						 | 
				
			
			@ -163,8 +180,8 @@ body.editor-is-fullscreen {
 | 
			
		|||
.editor-separator {
 | 
			
		||||
  display: block;
 | 
			
		||||
  height: 1px;
 | 
			
		||||
  background-color: #DDD;
 | 
			
		||||
  opacity: .8;
 | 
			
		||||
  @include mixins.lightDark(background-color, #DDD, #000);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.editor-format-menu-toggle {
 | 
			
		||||
| 
						 | 
				
			
			@ -199,6 +216,7 @@ body.editor-is-fullscreen {
 | 
			
		|||
  display: flex;
 | 
			
		||||
  border-inline: 1px solid #DDD;
 | 
			
		||||
  padding-inline: 4px;
 | 
			
		||||
  @include mixins.lightDark(border-color, #DDD, #000);
 | 
			
		||||
  &:first-child {
 | 
			
		||||
    border-inline-start: none;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -212,11 +230,12 @@ body.editor-is-fullscreen {
 | 
			
		|||
 | 
			
		||||
.editor-context-toolbar {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  background-color: #FFF;
 | 
			
		||||
  border: 1px solid #DDD;
 | 
			
		||||
  @include mixins.lightDark(background-color, #FFF, #222);
 | 
			
		||||
  @include mixins.lightDark(border-color, #DDD, #333);
 | 
			
		||||
  @include mixins.lightDark(box-shadow, 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 1px 4px 0 rgba(0, 0, 0, 0.4));
 | 
			
		||||
  padding: .2rem;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  &:before {
 | 
			
		||||
| 
						 | 
				
			
			@ -226,9 +245,10 @@ body.editor-is-fullscreen {
 | 
			
		|||
    width: 8px;
 | 
			
		||||
    height: 8px;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    background-color: #FFF;
 | 
			
		||||
    @include mixins.lightDark(background-color, #FFF, #222);
 | 
			
		||||
    border-top: 1px solid #DDD;
 | 
			
		||||
    border-left: 1px solid #DDD;
 | 
			
		||||
    @include mixins.lightDark(border-color, #DDD, #333);
 | 
			
		||||
    transform: rotate(45deg);
 | 
			
		||||
    left: 50%;
 | 
			
		||||
    margin-left: -4px;
 | 
			
		||||
| 
						 | 
				
			
			@ -252,10 +272,13 @@ body.editor-is-fullscreen {
 | 
			
		|||
  height: 100%;
 | 
			
		||||
}
 | 
			
		||||
.editor-modal {
 | 
			
		||||
  background-color: #FFF;
 | 
			
		||||
  @include mixins.lightDark(background-color, #FFF, #222);
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3);
 | 
			
		||||
  margin: vars.$xs;
 | 
			
		||||
  max-height: 100%;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
}
 | 
			
		||||
.editor-modal-header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
| 
						 | 
				
			
			@ -314,7 +337,8 @@ body.editor-is-fullscreen {
 | 
			
		|||
  display: flex;
 | 
			
		||||
}
 | 
			
		||||
.editor-table-creator-cell {
 | 
			
		||||
  border: 1px solid #DDD;
 | 
			
		||||
  border: 1px solid;
 | 
			
		||||
  @include mixins.lightDark(border-color, #DDD, #000);
 | 
			
		||||
  width: 15px;
 | 
			
		||||
  height: 15px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
| 
						 | 
				
			
			@ -326,6 +350,13 @@ body.editor-is-fullscreen {
 | 
			
		|||
  text-align: center;
 | 
			
		||||
  padding: 0.2em;
 | 
			
		||||
}
 | 
			
		||||
.editor-external-content {
 | 
			
		||||
  min-width: 500px;
 | 
			
		||||
  min-height: 500px;
 | 
			
		||||
  h4:first-child {
 | 
			
		||||
    margin-top: 0;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// In-editor elements
 | 
			
		||||
.editor-image-wrap {
 | 
			
		||||
| 
						 | 
				
			
			@ -347,7 +378,7 @@ body.editor-is-fullscreen {
 | 
			
		|||
  height: 10px;
 | 
			
		||||
  border: 2px solid var(--editor-color-primary);
 | 
			
		||||
  z-index: 3;
 | 
			
		||||
  background-color: #FFF;
 | 
			
		||||
  @include mixins.lightDark(background-color, #FFF, #000);
 | 
			
		||||
  user-select: none;
 | 
			
		||||
  &.nw {
 | 
			
		||||
    inset-inline-start: -5px;
 | 
			
		||||
| 
						 | 
				
			
			@ -470,18 +501,29 @@ body.editor-is-fullscreen {
 | 
			
		|||
/**
 | 
			
		||||
 * Form elements
 | 
			
		||||
 */
 | 
			
		||||
$inputWidth: 260px;
 | 
			
		||||
 | 
			
		||||
.editor-form-field-wrapper {
 | 
			
		||||
  margin-bottom: .5rem;
 | 
			
		||||
}
 | 
			
		||||
.editor-form-field-input {
 | 
			
		||||
  display: block;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  min-width: 250px;
 | 
			
		||||
  border: 1px solid #DDD;
 | 
			
		||||
  width: $inputWidth;
 | 
			
		||||
  min-width: 100px;
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
  border: 1px solid;
 | 
			
		||||
  @include mixins.lightDark(border-color, #DDD, #000);
 | 
			
		||||
  padding: .5rem;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  color: #444;
 | 
			
		||||
  @include mixins.lightDark(color, #444, #BBB);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@include mixins.smaller-than(vars.$bp-xs) {
 | 
			
		||||
  .editor-form-field-input {
 | 
			
		||||
    min-width: 160px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
textarea.editor-form-field-input {
 | 
			
		||||
  font-family: var(--font-code);
 | 
			
		||||
  width: 350px;
 | 
			
		||||
| 
						 | 
				
			
			@ -554,10 +596,21 @@ textarea.editor-form-field-input {
 | 
			
		|||
  align-items: stretch;
 | 
			
		||||
  gap: .25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@include mixins.smaller-than(vars.$bp-m) {
 | 
			
		||||
  .editor-form-tab-container {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    gap: .5rem;
 | 
			
		||||
  }
 | 
			
		||||
  .editor-form-tab-controls {
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.editor-form-tab-control {
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  color: #444;
 | 
			
		||||
  @include mixins.lightDark(color, #444, #666);
 | 
			
		||||
  border-bottom: 2px solid transparent;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
| 
						 | 
				
			
			@ -565,7 +618,7 @@ textarea.editor-form-field-input {
 | 
			
		|||
  text-align: start;
 | 
			
		||||
  &[aria-selected="true"] {
 | 
			
		||||
    border-color: var(--editor-color-primary);
 | 
			
		||||
    color: var(--editor-color-primary);
 | 
			
		||||
    color: var(--editor-color-primary) !important;
 | 
			
		||||
  }
 | 
			
		||||
  &[aria-selected="true"]:after, &:hover:after {
 | 
			
		||||
    background-color: var(--editor-color-primary);
 | 
			
		||||
| 
						 | 
				
			
			@ -580,7 +633,8 @@ textarea.editor-form-field-input {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
.editor-form-tab-contents {
 | 
			
		||||
  width: 360px;
 | 
			
		||||
  width: $inputWidth;
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
}
 | 
			
		||||
.editor-action-input-container {
 | 
			
		||||
  display: flex;
 | 
			
		||||
| 
						 | 
				
			
			@ -591,6 +645,9 @@ textarea.editor-form-field-input {
 | 
			
		|||
  .editor-button {
 | 
			
		||||
    margin-bottom: 12px;
 | 
			
		||||
  }
 | 
			
		||||
  input {
 | 
			
		||||
    width: $inputWidth - 40px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Editor theme styles
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,6 +26,7 @@
 | 
			
		|||
  width: 100%;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  box-shadow: vars.$bs-card;
 | 
			
		||||
  min-width: 300px;
 | 
			
		||||
  @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')
 | 
			
		||||
@section('document-class', 'bg-white ' .  (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : ''))
 | 
			
		||||
<h4>{{ trans('editor.shortcuts') }}</h4>
 | 
			
		||||
 | 
			
		||||
@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
 | 
			
		||||
<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>
 | 
			
		||||
 | 
			
		||||
<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');
 | 
			
		||||
 | 
			
		||||
// Metadata routes
 | 
			
		||||
Route::view('/help/tinymce', 'help.tinymce');
 | 
			
		||||
Route::view('/help/wysiwyg', 'help.wysiwyg');
 | 
			
		||||
 | 
			
		||||
Route::fallback([MetaController::class, 'notFound'])->name('fallback');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,9 +6,9 @@ use Tests\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();
 | 
			
		||||
        $this->withHtml($resp)->assertElementExists('a[href="https://www.tiny.cloud/"]');
 | 
			
		||||
        $this->withHtml($resp)->assertElementExists('a[href="' . url('/libs/tinymce/license.txt') . '"]');
 | 
			
		||||
| 
						 | 
				
			
			@ -22,4 +22,12 @@ class HelpTest extends TestCase
 | 
			
		|||
        $contents = file_get_contents($expectedPath);
 | 
			
		||||
        $this->assertStringContainsString('MIT License', $contents);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_wysiwyg_help_shows_lexical_and_licenses_link()
 | 
			
		||||
    {
 | 
			
		||||
        $resp = $this->get('/help/wysiwyg');
 | 
			
		||||
        $resp->assertOk();
 | 
			
		||||
        $this->withHtml($resp)->assertElementExists('a[href="https://lexical.dev/"]');
 | 
			
		||||
        $this->withHtml($resp)->assertElementExists('a[href="' . url('/licenses') . '"]');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue