Skip to content

Commit 1f88bc2

Browse files
authored
Merge pull request #5365 from BookStackApp/lexical_fixes
Range of fixes/updates for the new Lexical based editor
2 parents a8ef820 + ebe2ca7 commit 1f88bc2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2028
-823
lines changed

dev/build/svg-blank-transform.js

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// This is a basic transformer stub to help jest handle SVG files.
2+
// Essentially blanks them since we don't really need to involve them
3+
// in our tests (yet).
4+
module.exports = {
5+
process() {
6+
return {
7+
code: 'module.exports = \'\';',
8+
};
9+
},
10+
getCacheKey() {
11+
// The output is always the same.
12+
return 'svgTransform';
13+
},
14+
};

jest.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ const config: Config = {
185185
// A map from regular expressions to paths to transformers
186186
transform: {
187187
"^.+.tsx?$": ["ts-jest",{}],
188+
"^.+.svg$": ["<rootDir>/dev/build/svg-blank-transform.js",{}],
188189
},
189190

190191
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation

lang/en/editor.php

+2
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@
163163
'about' => 'About the editor',
164164
'about_title' => 'About the WYSIWYG Editor',
165165
'editor_license' => 'Editor License & Copyright',
166+
'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
167+
'editor_lexical_license_link' => 'Full license details can be found here.',
166168
'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.',
167169
'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.',
168170
'save_continue' => 'Save Page & Continue',
File renamed without changes.
Loading

resources/js/wysiwyg-tinymce/plugins-about.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
function register(editor) {
55
const aboutDialog = {
66
title: 'About the WYSIWYG Editor',
7-
url: window.baseUrl('/help/wysiwyg'),
7+
url: window.baseUrl('/help/tinymce'),
88
};
99

1010
editor.ui.registry.addButton('about', {

resources/js/wysiwyg/index.ts

+7-31
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {el} from "./utils/dom";
1515
import {registerShortcuts} from "./services/shortcuts";
1616
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
1717
import {registerKeyboardHandling} from "./services/keyboard-handling";
18+
import {registerAutoLinks} from "./services/auto-links";
1819

1920
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
2021
const config: CreateEditorArgs = {
@@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
6465
registerTaskListHandler(editor, editArea),
6566
registerDropPasteHandling(context),
6667
registerNodeResizer(context),
68+
registerAutoLinks(editor),
6769
);
6870

6971
listenToCommonEvents(editor);
@@ -73,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
7375
const debugView = document.getElementById('lexical-debug');
7476
if (debugView) {
7577
debugView.hidden = true;
76-
}
77-
78-
let changeFromLoading = true;
79-
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
80-
// Watch for selection changes to update the UI on change
81-
// Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
82-
// for all selection changes, so this proved more reliable.
83-
const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
84-
if (selectionChange) {
85-
editor.update(() => {
86-
const selection = $getSelection();
87-
context.manager.triggerStateUpdate({
88-
editor, selection,
89-
});
90-
});
91-
}
92-
93-
// Emit change event to component system (for draft detection) on actual user content change
94-
if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
95-
if (changeFromLoading) {
96-
changeFromLoading = false;
97-
} else {
98-
window.$events.emit('editor-html-change', '');
99-
}
100-
}
101-
102-
// Debug logic
103-
// console.log('editorState', editorState.toJSON());
104-
if (debugView) {
78+
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
79+
// Debug logic
80+
// console.log('editorState', editorState.toJSON());
10581
debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
106-
}
107-
});
82+
});
83+
}
10884

10985
// @ts-ignore
11086
window.debugEditorState = () => {

resources/js/wysiwyg/lexical/core/LexicalEditor.ts

+8
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,14 @@ export class LexicalEditor {
11881188
updateEditor(this, updateFn, options);
11891189
}
11901190

1191+
/**
1192+
* Helper to run the update and commitUpdates methods in a single call.
1193+
*/
1194+
updateAndCommit(updateFn: () => void, options?: EditorUpdateOptions): void {
1195+
this.update(updateFn, options);
1196+
this.commitUpdates();
1197+
}
1198+
11911199
/**
11921200
* Focuses the editor
11931201
* @param callbackFn - A function to run after the editor is focused.

resources/js/wysiwyg/lexical/core/LexicalNode.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,15 @@ export type DOMConversionMap<T extends HTMLElement = HTMLElement> = Record<
142142
>;
143143
type NodeName = string;
144144

145+
/**
146+
* Output for a DOM conversion.
147+
* Node can be set to 'ignore' to ignore the conversion and handling of the DOMNode
148+
* including all its children.
149+
*/
145150
export type DOMConversionOutput = {
146151
after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
147152
forChild?: DOMChildConversion;
148-
node: null | LexicalNode | Array<LexicalNode>;
153+
node: null | LexicalNode | Array<LexicalNode> | 'ignore';
149154
};
150155

151156
export type DOMExportOutputMap = Map<

resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts

+105-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list';
1313
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
1414

1515
import {
16+
$getSelection,
1617
$isRangeSelection,
1718
createEditor,
1819
DecoratorNode,
@@ -29,14 +30,14 @@ import {
2930
TextNode,
3031
} from 'lexical';
3132

32-
import {
33-
CreateEditorArgs,
34-
HTMLConfig,
35-
LexicalNodeReplacement,
36-
} from '../../LexicalEditor';
33+
import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor';
3734
import {resetRandomKey} from '../../LexicalUtils';
3835
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
3936
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
37+
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
38+
import {EditorUiContext} from "../../../../ui/framework/core";
39+
import {EditorUIManager} from "../../../../ui/framework/manager";
40+
import {turtle} from "@codemirror/legacy-modes/mode/turtle";
4041

4142

4243
type TestEnv = {
@@ -420,6 +421,7 @@ const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeR
420421
TableRowNode,
421422
AutoLinkNode,
422423
LinkNode,
424+
DetailsNode,
423425
TestElementNode,
424426
TestSegmentedNode,
425427
TestExcludeFromCopyElementNode,
@@ -451,6 +453,7 @@ export function createTestEditor(
451453
...config,
452454
nodes: DEFAULT_NODES.concat(customNodes),
453455
});
456+
454457
return editor;
455458
}
456459

@@ -465,6 +468,48 @@ export function createTestHeadlessEditor(
465468
});
466469
}
467470

471+
export function createTestContext(): EditorUiContext {
472+
473+
const container = document.createElement('div');
474+
document.body.appendChild(container);
475+
476+
const scrollWrap = document.createElement('div');
477+
const editorDOM = document.createElement('div');
478+
editorDOM.setAttribute('contenteditable', 'true');
479+
480+
scrollWrap.append(editorDOM);
481+
container.append(scrollWrap);
482+
483+
const editor = createTestEditor({
484+
namespace: 'testing',
485+
theme: {},
486+
});
487+
488+
editor.setRootElement(editorDOM);
489+
490+
const context = {
491+
containerDOM: container,
492+
editor: editor,
493+
editorDOM: editorDOM,
494+
error(text: string | Error): void {
495+
},
496+
manager: new EditorUIManager(),
497+
options: {},
498+
scrollDOM: scrollWrap,
499+
translate(text: string): string {
500+
return "";
501+
}
502+
};
503+
504+
context.manager.setContext(context);
505+
506+
return context;
507+
}
508+
509+
export function destroyFromContext(context: EditorUiContext) {
510+
context.containerDOM.remove();
511+
}
512+
468513
export function $assertRangeSelection(selection: unknown): RangeSelection {
469514
if (!$isRangeSelection(selection)) {
470515
throw new Error(`Expected RangeSelection, got ${selection}`);
@@ -715,6 +760,61 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
715760
expect(formatHtml(expected)).toBe(formatHtml(actual));
716761
}
717762

763+
type nodeTextShape = {
764+
text: string;
765+
};
766+
767+
type nodeShape = {
768+
type: string;
769+
children?: (nodeShape|nodeTextShape)[];
770+
}
771+
772+
export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape {
773+
// @ts-ignore
774+
const children: SerializedLexicalNode[] = (node.children || []);
775+
776+
const shape: nodeShape = {
777+
type: node.type,
778+
};
779+
780+
if (shape.type === 'text') {
781+
// @ts-ignore
782+
return {text: node.text}
783+
}
784+
785+
if (children.length > 0) {
786+
shape.children = children.map(c => getNodeShape(c));
787+
}
788+
789+
return shape;
790+
}
791+
792+
export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) {
793+
const json = editor.getEditorState().toJSON();
794+
const shape = getNodeShape(json.root) as nodeShape;
795+
expect(shape.children).toMatchObject(expected);
796+
}
797+
718798
function formatHtml(s: string): string {
719799
return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
800+
}
801+
802+
export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) {
803+
const nodeDomEl = editor.getElementByKey(node.getKey());
804+
const event = new KeyboardEvent('keydown', {
805+
bubbles: true,
806+
cancelable: true,
807+
key,
808+
});
809+
nodeDomEl?.dispatchEvent(event);
810+
editor.commitUpdates();
811+
}
812+
813+
export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
814+
editor.getEditorState().read((): void => {
815+
const node = $getSelection()?.getNodes()[0] || null;
816+
if (node) {
817+
dispatchKeydownEventForNode(node, editor, key);
818+
}
819+
});
720820
}

resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts

-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ describe('LexicalHeadlessEditor', () => {
6262
it('should be headless environment', async () => {
6363
expect(typeof window === 'undefined').toBe(true);
6464
expect(typeof document === 'undefined').toBe(true);
65-
expect(typeof navigator === 'undefined').toBe(true);
6665
});
6766

6867
it('can update editor', async () => {

resources/js/wysiwyg/lexical/html/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,11 @@ function $createNodesFromDOM(
217217
if (transformOutput !== null) {
218218
postTransform = transformOutput.after;
219219
const transformNodes = transformOutput.node;
220+
221+
if (transformNodes === 'ignore') {
222+
return lexicalNodes;
223+
}
224+
220225
currentLexicalNode = Array.isArray(transformNodes)
221226
? transformNodes[transformNodes.length - 1]
222227
: transformNodes;

resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,18 @@ export class ListItemNode extends ElementNode {
271271
insertNewAfter(
272272
_: RangeSelection,
273273
restoreSelection = true,
274-
): ListItemNode | ParagraphNode {
274+
): ListItemNode | ParagraphNode | null {
275275

276276
if (this.getTextContent().trim() === '' && this.isLastChild()) {
277277
const list = this.getParentOrThrow<ListNode>();
278-
if (!$isListItemNode(list.getParent())) {
278+
const parentListItem = list.getParent();
279+
if ($isListItemNode(parentListItem)) {
280+
// Un-nest list item if empty nested item
281+
parentListItem.insertAfter(this);
282+
this.selectStart();
283+
return null;
284+
} else {
285+
// Insert empty paragraph after list if adding after last empty child
279286
const paragraph = $createParagraphNode();
280287
list.insertAfter(paragraph, restoreSelection);
281288
this.remove();

0 commit comments

Comments
 (0)