Skip to content

Commit

Permalink
Merge pull request #5365 from BookStackApp/lexical_fixes
Browse files Browse the repository at this point in the history
Range of fixes/updates for the new Lexical based editor
  • Loading branch information
ssddanbrown authored Dec 20, 2024
2 parents a8ef820 + ebe2ca7 commit 1f88bc2
Show file tree
Hide file tree
Showing 43 changed files with 2,028 additions and 823 deletions.
14 changes: 14 additions & 0 deletions dev/build/svg-blank-transform.js
Original file line number Diff line number Diff line change
@@ -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';
},
};
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lang/en/editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@
'about' => 'About the editor',
'about_title' => 'About the WYSIWYG Editor',
'editor_license' => 'Editor License & Copyright',
'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
'editor_lexical_license_link' => 'Full license details can be found here.',
'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.',
'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.',
'save_continue' => 'Save Page & Continue',
Expand Down
File renamed without changes
1 change: 1 addition & 0 deletions resources/icons/editor/details-toggle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion resources/js/wysiwyg-tinymce/plugins-about.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
38 changes: 7 additions & 31 deletions resources/js/wysiwyg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
registerTaskListHandler(editor, editArea),
registerDropPasteHandling(context),
registerNodeResizer(context),
registerAutoLinks(editor),
);

listenToCommonEvents(editor);
Expand All @@ -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 = () => {
Expand Down
8 changes: 8 additions & 0 deletions resources/js/wysiwyg/lexical/core/LexicalEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion resources/js/wysiwyg/lexical/core/LexicalNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
110 changes: 105 additions & 5 deletions resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list';
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';

import {
$getSelection,
$isRangeSelection,
createEditor,
DecoratorNode,
Expand All @@ -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 = {
Expand Down Expand Up @@ -420,6 +421,7 @@ const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeR
TableRowNode,
AutoLinkNode,
LinkNode,
DetailsNode,
TestElementNode,
TestSegmentedNode,
TestExcludeFromCopyElementNode,
Expand Down Expand Up @@ -451,6 +453,7 @@ export function createTestEditor(
...config,
nodes: DEFAULT_NODES.concat(customNodes),
});

return editor;
}

Expand All @@ -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}`);
Expand Down Expand Up @@ -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);
}
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
5 changes: 5 additions & 0 deletions resources/js/wysiwyg/lexical/html/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 9 additions & 2 deletions resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading

0 comments on commit 1f88bc2

Please sign in to comment.