0
0
Fork 0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-04-22 20:02:30 +00:00

Merge pull request from BookStackApp/more_lexical_fixes

Further Lexical Fixes
This commit is contained in:
Dan Brown 2025-02-16 15:28:55 +00:00 committed by GitHub
commit 213a86e3c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 573 additions and 241 deletions

View file

@ -13,6 +13,7 @@ return [
'cancel' => 'Cancel',
'save' => 'Save',
'close' => 'Close',
'apply' => 'Apply',
'undo' => 'Undo',
'redo' => 'Redo',
'left' => 'Left',
@ -147,6 +148,7 @@ return [
'url' => 'URL',
'text_to_display' => 'Text to display',
'title' => 'Title',
'browse_links' => 'Browse links',
'open_link' => 'Open link',
'open_link_in' => 'Open link in...',
'open_link_current' => 'Current window',

View file

@ -0,0 +1,10 @@
<svg version="1.1" viewBox="0 -960 960 960" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<pattern id="pattern2" x="0.40000001" patternTransform="scale(200)" preserveAspectRatio="xMidYMid" xlink:href="#Checkerboard"/>
<pattern id="Checkerboard" width="2" height="2" fill="#b6b6b6" patternTransform="translate(0) scale(10)" patternUnits="userSpaceOnUse" preserveAspectRatio="xMidYMid">
<rect width="1" height="1"/>
<rect x="1" y="1" width="1" height="1"/>
</pattern>
</defs>
<rect class="editor-icon-color-display" x="103.53" y="-856.47" width="752.94" height="752.94" rx="47.059" ry="47.059" fill="url(#pattern2)" stroke="#666" stroke-linecap="square" stroke-linejoin="round" stroke-width="47.059"/>
</svg>

After

(image error) Size: 762 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 32.5-156t88-127Q256-817 330-848.5T488-880q80 0 151 27.5t124.5 76q53.5 48.5 85 115T880-518q0 115-70 176.5T640-280h-74q-9 0-12.5 5t-3.5 11q0 12 15 34.5t15 51.5q0 50-27.5 74T480-80Zm0-400Zm-220 40q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm120-160q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm200 0q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm120 160q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17ZM480-160q9 0 14.5-5t5.5-13q0-14-15-33t-15-57q0-42 29-67t71-25h70q66 0 113-38.5T800-518q0-121-92.5-201.5T488-800q-136 0-232 93t-96 227q0 133 93.5 226.5T480-160Z"/></svg>

After

(image error) Size: 808 B

View file

@ -8,7 +8,6 @@
import type {
BaseSelection,
ElementFormatType,
LexicalCommand,
LexicalNode,
TextFormatType,
@ -91,8 +90,6 @@ export const OUTDENT_CONTENT_COMMAND: LexicalCommand<void> = createCommand(
);
export const DROP_COMMAND: LexicalCommand<DragEvent> =
createCommand('DROP_COMMAND');
export const FORMAT_ELEMENT_COMMAND: LexicalCommand<ElementFormatType> =
createCommand('FORMAT_ELEMENT_COMMAND');
export const DRAGSTART_COMMAND: LexicalCommand<DragEvent> =
createCommand('DRAGSTART_COMMAND');
export const DRAGOVER_COMMAND: LexicalCommand<DragEvent> =

View file

@ -6,7 +6,6 @@
*
*/
import type {ElementFormatType} from './nodes/LexicalElementNode';
import type {
TextDetailType,
TextFormatType,
@ -111,27 +110,6 @@ export const DETAIL_TYPE_TO_DETAIL: Record<TextDetailType | string, number> = {
unmergeable: IS_UNMERGEABLE,
};
export const ELEMENT_TYPE_TO_FORMAT: Record<
Exclude<ElementFormatType, ''>,
number
> = {
center: IS_ALIGN_CENTER,
end: IS_ALIGN_END,
justify: IS_ALIGN_JUSTIFY,
left: IS_ALIGN_LEFT,
right: IS_ALIGN_RIGHT,
start: IS_ALIGN_START,
};
export const ELEMENT_FORMAT_TO_TYPE: Record<number, ElementFormatType> = {
[IS_ALIGN_CENTER]: 'center',
[IS_ALIGN_END]: 'end',
[IS_ALIGN_JUSTIFY]: 'justify',
[IS_ALIGN_LEFT]: 'left',
[IS_ALIGN_RIGHT]: 'right',
[IS_ALIGN_START]: 'start',
};
export const TEXT_MODE_TO_TYPE: Record<TextModeType, 0 | 1 | 2> = {
normal: IS_NORMAL,
segmented: IS_SEGMENTED,

View file

@ -146,6 +146,12 @@ 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.
*
* You can specify a function to run for each converted child (forChild) or on all
* the child nodes after the conversion is complete (after).
* The key difference here is that forChild runs for every deeply nested child node
* of the current node, whereas after will run only once after the
* transformation of the node and all its children is complete.
*/
export type DOMConversionOutput = {
after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
@ -1165,6 +1171,16 @@ export class LexicalNode {
markDirty(): void {
this.getWritable();
}
/**
* Insert the DOM of this node into that of the parent.
* Allows this node to implement custom DOM attachment logic.
* Boolean result indicates if the insertion was handled by the function.
* A true return value prevents default insertion logic from taking place.
*/
insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean {
return false;
}
}
function errorOnTypeKlassMismatch(

View file

@ -171,16 +171,21 @@ function $createNode(
}
if (parentDOM !== null) {
if (insertDOM != null) {
parentDOM.insertBefore(dom, insertDOM);
} else {
// @ts-expect-error: internal field
const possibleLineBreak = parentDOM.__lexicalLineBreak;
if (possibleLineBreak != null) {
parentDOM.insertBefore(dom, possibleLineBreak);
const inserted = node?.insertDOMIntoParent(dom, parentDOM);
if (!inserted) {
if (insertDOM != null) {
parentDOM.insertBefore(dom, insertDOM);
} else {
parentDOM.appendChild(dom);
// @ts-expect-error: internal field
const possibleLineBreak = parentDOM.__lexicalLineBreak;
if (possibleLineBreak != null) {
parentDOM.insertBefore(dom, possibleLineBreak);
} else {
parentDOM.appendChild(dom);
}
}
}
}

View file

@ -37,8 +37,6 @@ 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 = {
readonly container: HTMLDivElement;
@ -47,6 +45,9 @@ type TestEnv = {
readonly innerHTML: string;
};
/**
* @deprecated - Consider using `createTestContext` instead within the test case.
*/
export function initializeUnitTest(
runTests: (testEnv: TestEnv) => void,
editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}},
@ -795,6 +796,30 @@ export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShap
expect(shape.children).toMatchObject(expected);
}
/**
* Expect a given prop within the JSON editor state structure to be the given value.
* Uses dot notation for the provided `propPath`. Example:
* 0.5.cat => First child, Sixth child, cat property
*/
export function expectEditorStateJSONPropToEqual(editor: LexicalEditor, propPath: string, expected: any) {
let currentItem: any = editor.getEditorState().toJSON().root;
let currentPath = [];
const pathParts = propPath.split('.');
for (const part of pathParts) {
currentPath.push(part);
const childAccess = Number.isInteger(Number(part)) && Array.isArray(currentItem.children);
const target = childAccess ? currentItem.children : currentItem;
if (typeof target[part] === 'undefined') {
throw new Error(`Could not resolve editor state at path ${currentPath.join('.')}`)
}
currentItem = target[part];
}
expect(currentItem).toBe(expected);
}
function formatHtml(s: string): string {
return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
}

View file

@ -49,15 +49,12 @@ export type {
} from './LexicalNode';
export type {
BaseSelection,
ElementPointType as ElementPoint,
NodeSelection,
Point,
PointType,
RangeSelection,
TextPointType as TextPoint,
} from './LexicalSelection';
export type {
ElementFormatType,
SerializedElementNode,
} from './nodes/LexicalElementNode';
export type {SerializedRootNode} from './nodes/LexicalRootNode';
@ -87,7 +84,6 @@ export {
DRAGSTART_COMMAND,
DROP_COMMAND,
FOCUS_COMMAND,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
INDENT_CONTENT_COMMAND,
INSERT_LINE_BREAK_COMMAND,

View file

@ -46,15 +46,6 @@ export type SerializedElementNode<
SerializedLexicalNode
>;
export type ElementFormatType =
| 'left'
| 'start'
| 'center'
| 'right'
| 'end'
| 'justify'
| '';
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface ElementNode {
getTopLevelElement(): ElementNode | null;

View file

@ -1314,6 +1314,11 @@ const nodeNameToTextFormat: Record<string, TextFormatType> = {
function convertTextFormatElement(domNode: HTMLElement): DOMConversionOutput {
const format = nodeNameToTextFormat[domNode.nodeName.toLowerCase()];
if (format === 'code' && domNode.closest('pre')) {
return {node: null};
}
if (format === undefined) {
return {node: null};
}

View file

@ -18,9 +18,9 @@ export default function invariant(
return;
}
throw new Error(
'Internal Lexical error: invariant() is meant to be replaced at compile ' +
'time. There is no runtime version. Error: ' +
message,
);
for (const arg of args) {
message = (message || '').replace('%s', arg);
}
throw new Error(message);
}

View file

@ -11,7 +11,6 @@ import type {
DOMChildConversion,
DOMConversion,
DOMConversionFn,
ElementFormatType,
LexicalEditor,
LexicalNode,
} from 'lexical';
@ -58,6 +57,7 @@ export function $generateNodesFromDOM(
}
}
}
$unwrapArtificalNodes(allArtificialNodes);
return lexicalNodes;
@ -324,8 +324,6 @@ function wrapContinuousInlines(
nodes: Array<LexicalNode>,
createWrapperFn: () => ElementNode,
): Array<LexicalNode> {
const textAlign = (domNode as HTMLElement).style
.textAlign as ElementFormatType;
const out: Array<LexicalNode> = [];
let continuousInlines: Array<LexicalNode> = [];
// wrap contiguous inline child nodes in para

View file

@ -145,7 +145,14 @@ export class CodeBlockNode extends DecoratorNode<EditorDecoratorAdapter> {
node.setId(element.id);
}
return { node };
return {
node,
after(childNodes): LexicalNode[] {
// Remove any child nodes that may get parsed since we're manually
// controlling the code contents.
return [];
},
};
},
priority: 3,
};

View file

@ -16,6 +16,7 @@ import {
} from "lexical/nodes/common";
import {$selectSingleNode} from "../../utils/selection";
import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
import * as url from "node:url";
export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
export type MediaNodeSource = {
@ -343,11 +344,55 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null {
return domElementToNode(tag as MediaNodeTag, el);
}
interface UrlPattern {
readonly regex: RegExp;
readonly w: number;
readonly h: number;
readonly url: string;
}
/**
* These patterns originate from the tinymce/tinymce project.
* https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts
* License: MIT Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
* License Link: https://github.com/tinymce/tinymce/blob/584a150679669859a528828e5d2910a083b1d911/LICENSE.TXT
*/
const urlPatterns: UrlPattern[] = [
{
regex: /.*?youtu\.be\/([\w\-_\?&=.]+)/i,
w: 560, h: 314,
url: 'https://www.youtube.com/embed/$1',
},
{
regex: /.*youtube\.com(.+)v=([^&]+)(&([a-z0-9&=\-_]+))?.*/i,
w: 560, h: 314,
url: 'https://www.youtube.com/embed/$2?$4',
},
{
regex: /.*youtube.com\/embed\/([a-z0-9\?&=\-_]+).*/i,
w: 560, h: 314,
url: 'https://www.youtube.com/embed/$1',
},
];
const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov'];
const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm'];
const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx', ''];
export function $createMediaNodeFromSrc(src: string): MediaNode {
for (const pattern of urlPatterns) {
const match = src.match(pattern.regex);
if (match) {
const newSrc = src.replace(pattern.regex, pattern.url);
const node = new MediaNode('iframe');
node.setSrc(newSrc);
node.setHeight(pattern.h);
node.setWidth(pattern.w);
return node;
}
}
let nodeTag: MediaNodeTag = 'iframe';
const srcEnd = src.split('?')[0].split('/').pop() || '';
const srcEndSplit = srcEnd.split('.');
@ -360,7 +405,9 @@ export function $createMediaNodeFromSrc(src: string): MediaNode {
nodeTag = 'embed';
}
return new MediaNode(nodeTag);
const node = new MediaNode(nodeTag);
node.setSrc(src);
return node;
}
export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode {

View file

@ -8,7 +8,6 @@
import type {
CommandPayloadType,
ElementFormatType,
LexicalCommand,
LexicalEditor,
PasteCommandType,
@ -44,7 +43,6 @@ import {
DRAGSTART_COMMAND,
DROP_COMMAND,
ElementNode,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
INSERT_LINE_BREAK_COMMAND,
INSERT_PARAGRAPH_COMMAND,
@ -285,25 +283,6 @@ export function registerRichText(editor: LexicalEditor): () => void {
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand<ElementFormatType>(
FORMAT_ELEMENT_COMMAND,
(format) => {
const selection = $getSelection();
if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
return false;
}
const nodes = selection.getNodes();
for (const node of nodes) {
const element = $findMatchingParent(
node,
(parentNode): parentNode is ElementNode =>
$isElementNode(parentNode) && !parentNode.isInline(),
);
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand<boolean>(
INSERT_LINE_BREAK_COMMAND,
(selectStart) => {

View file

@ -0,0 +1,92 @@
import {
$createTextNode,
DOMConversionMap,
DOMExportOutput,
EditorConfig,
ElementNode,
LexicalEditor,
LexicalNode,
SerializedElementNode
} from "lexical";
import {TableNode} from "@lexical/table/LexicalTableNode";
export class CaptionNode extends ElementNode {
static getType(): string {
return 'caption';
}
static clone(node: CaptionNode): CaptionNode {
return new CaptionNode(node.__key);
}
createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement {
return document.createElement('caption');
}
updateDOM(_prevNode: unknown, _dom: HTMLElement, _config: EditorConfig): boolean {
return false;
}
isParentRequired(): true {
return true;
}
canBeEmpty(): boolean {
return false;
}
exportJSON(): SerializedElementNode {
return {
...super.exportJSON(),
type: 'caption',
version: 1,
};
}
insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean {
parentDOM.insertBefore(nodeDOM, parentDOM.firstChild);
return true;
}
static importJSON(serializedNode: SerializedElementNode): CaptionNode {
return $createCaptionNode();
}
static importDOM(): DOMConversionMap | null {
return {
caption: (node: Node) => ({
conversion(domNode: Node) {
return {
node: $createCaptionNode(),
}
},
priority: 0,
}),
};
}
}
export function $createCaptionNode(): CaptionNode {
return new CaptionNode();
}
export function $isCaptionNode(node: LexicalNode | null | undefined): node is CaptionNode {
return node instanceof CaptionNode;
}
export function $tableHasCaption(table: TableNode): boolean {
for (const child of table.getChildren()) {
if ($isCaptionNode(child)) {
return true;
}
}
return false;
}
export function $addCaptionToTable(table: TableNode, text: string = ''): void {
const caption = $createCaptionNode();
const textNode = $createTextNode(text || ' ');
caption.append(textNode);
table.append(caption);
}

View file

@ -139,6 +139,8 @@ export class TableNode extends CommonBlockNode {
for (const child of Array.from(tableElement.children)) {
if (child.nodeName === 'TR') {
tBody.append(child);
} else if (child.nodeName === 'CAPTION') {
newElement.insertBefore(child, newElement.firstChild);
} else {
newElement.append(child);
}

View file

@ -18,6 +18,7 @@ import {EditorUiContext} from "./ui/framework/core";
import {MediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
import {CaptionNode} from "@lexical/table/LexicalCaptionNode";
/**
* Load the nodes for lexical.
@ -32,6 +33,7 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
TableNode,
TableRowNode,
TableCellNode,
CaptionNode,
ImageNode, // TODO - Alignment
HorizontalRuleNode,
DetailsNode,

View file

@ -1,91 +1,76 @@
import {initializeUnitTest} from "lexical/__tests__/utils";
import {SerializedLinkNode} from "@lexical/link";
import {
createTestContext,
dispatchKeydownEventForNode, expectEditorStateJSONPropToEqual,
expectNodeShapeToMatch
} from "lexical/__tests__/utils";
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} = createTestContext();
registerAutoLinks(editor);
let pNode!: ParagraphNode;
test('space after link in text', async () => {
const {editor} = testEnv;
editor.updateAndCommit(() => {
pNode = new ParagraphNode();
const text = new TextNode('Some https://example.com?test=true text');
pNode.append(text);
$getRoot().append(pNode);
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');
text.select(34, 34);
});
test('enter after link in text', async () => {
const {editor} = testEnv;
dispatchKeydownEventForNode(pNode, editor, ' ');
registerAutoLinks(editor);
let pNode!: ParagraphNode;
expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true');
expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true');
});
editor.update(() => {
pNode = new ParagraphNode();
const text = new TextNode('Some https://example.com?test=true text');
pNode.append(text);
$getRoot().append(pNode);
test('space after link at end of line', async () => {
const {editor} = createTestContext();
registerAutoLinks(editor);
let pNode!: ParagraphNode;
text.select(34, 34);
});
editor.updateAndCommit(() => {
pNode = new ParagraphNode();
const text = new TextNode('Some https://example.com?test=true');
pNode.append(text);
$getRoot().append(pNode);
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');
text.selectEnd();
});
dispatchKeydownEventForNode(pNode, editor, ' ');
expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [
{text: 'Some '},
{type: 'link', children: [{text: 'https://example.com?test=true'}]}
]}]);
expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true');
});
test('enter after link in text', async () => {
const {editor} = createTestContext();
registerAutoLinks(editor);
let pNode!: ParagraphNode;
editor.updateAndCommit(() => {
pNode = new ParagraphNode();
const text = new TextNode('Some https://example.com?test=true text');
pNode.append(text);
$getRoot().append(pNode);
text.select(34, 34);
});
dispatchKeydownEventForNode(pNode, editor, 'Enter');
expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true');
expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true');
});
});

View file

@ -43,7 +43,7 @@ function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, edit
linkNode.append(new TextNode(textSegment));
const splits = node.splitText(startIndex, cursorPoint);
const targetIndex = splits.length === 3 ? 1 : 0;
const targetIndex = startIndex > 0 ? 1 : 0;
const targetText = splits[targetIndex];
if (targetText) {
targetText.replace(linkNode);

View file

@ -0,0 +1,55 @@
# Testing Guidance
This is testing guidance specific for this Lexical-based WYSIWYG editor.
There is a lot of pre-existing test code carried over form the fork of lexical, but since there we've added a range of helpers and altered how testing can be done to make things a bit simpler and aligned with how we run tests.
This document is an attempt to document the new best options for added tests with an aim for standardisation on these approaches going forward.
## Utils Location
Most core test utils can be found in the file at path: resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
## Test Example
This is an example of a typical test using the common modern utilities to help perform actions or assertions. Comments are for this example only, and are not expected in actual test files.
```ts
import {
createTestContext,
dispatchKeydownEventForNode,
expectEditorStateJSONPropToEqual,
expectNodeShapeToMatch
} from "lexical/__tests__/utils";
import {
$getRoot,
ParagraphNode,
TextNode
} from "lexical";
describe('A specific service or file or function', () => {
test('it does thing', async () => {
// Create the editor context and get an editor reference
const {editor} = createTestContext();
// Run an action within the editor.
let pNode: ParagraphNode;
editor.updateAndCommit(() => {
pNode = new ParagraphNode();
const text = new TextNode('Hello!');
pNode.append(text);
$getRoot().append(pNode);
});
// Dispatch key events via the DOM
dispatchKeydownEventForNode(pNode!, editor, ' ');
// Check the shape (and text) of the resulting state
expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [
{text: 'Hello!'},
]}]);
// Check specific props in the resulting JSON state
expectEditorStateJSONPropToEqual(editor, '0.0.text', 'Hello!');
});
});
```

View file

@ -1,27 +0,0 @@
# Lexical based editor todo
## In progress
Reorg
- Merge custom nodes into original nodes
- Reduce down to use CommonBlockNode where possible
- Remove existing formatType/ElementFormatType references (replaced with alignment).
- Remove existing indent references (replaced with inset).
## Main Todo
//
## Secondary Todo
- Color picker support in table form color fields
- Color picker for color controls
- Table caption text support
- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
- Deep check of translation coverage
- About button & view
- Mobile display and handling
## Bugs
//

View file

@ -57,7 +57,7 @@ export const redo: EditorButtonDefinition = {
export const source: EditorButtonDefinition = {
label: 'Source',
label: 'Source code',
icon: sourceIcon,
async action(context: EditorUiContext) {
const modal = context.manager.createModal('source');

View file

@ -12,6 +12,8 @@ import subscriptIcon from "@icons/editor/subscript.svg";
import codeIcon from "@icons/editor/code.svg";
import formatClearIcon from "@icons/editor/format-clear.svg";
import {$selectionContainsTextFormat} from "../../../utils/selection";
import {$patchStyleText} from "@lexical/selection";
import {context} from "esbuild";
function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition {
return {
@ -32,6 +34,18 @@ export const underline: EditorButtonDefinition = buildFormatButton('Underline',
export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon};
export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon};
function colorAction(context: EditorUiContext, property: string, color: string): void {
context.editor.update(() => {
const selection = $getSelection();
if (selection) {
$patchStyleText(selection, {[property]: color || null});
}
});
}
export const textColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color);
export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color);
export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon);
export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon);
export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon);

View file

@ -32,7 +32,7 @@ import {
} from "../../../utils/selection";
import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams";
import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects";
import {$showDetailsForm, $showImageForm, $showLinkForm, $showMediaForm} from "../forms/objects";
import {formatCodeBlock} from "../../../utils/formats";
export const link: EditorButtonDefinition = {
@ -165,27 +165,14 @@ export const diagramManager: EditorButtonDefinition = {
};
export const media: EditorButtonDefinition = {
label: 'Insert/edit Media',
label: 'Insert/edit media',
icon: mediaIcon,
action(context: EditorUiContext) {
const mediaModal = context.manager.createModal('media');
context.editor.getEditorState().read(() => {
const selection = $getSelection();
const selectedNode = $getNodeFromSelection(selection, $isMediaNode) as MediaNode | null;
let formDefaults = {};
if (selectedNode) {
const nodeAttrs = selectedNode.getAttributes();
formDefaults = {
src: nodeAttrs.src || nodeAttrs.data || '',
width: nodeAttrs.width,
height: nodeAttrs.height,
embed: '',
}
}
mediaModal.show(formDefaults);
$showMediaForm(selectedNode, context);
});
},
isActive(selection: BaseSelection | null): boolean {

View file

@ -186,6 +186,23 @@ export const link: EditorFormDefinition = {
],
};
export function $showMediaForm(media: MediaNode|null, context: EditorUiContext): void {
const mediaModal = context.manager.createModal('media');
let formDefaults = {};
if (media) {
const nodeAttrs = media.getAttributes();
formDefaults = {
src: nodeAttrs.src || nodeAttrs.data || '',
width: nodeAttrs.width,
height: nodeAttrs.height,
embed: '',
}
}
mediaModal.show(formDefaults);
}
export const media: EditorFormDefinition = {
submitText: 'Save',
async action(formData, context: EditorUiContext) {
@ -215,12 +232,19 @@ export const media: EditorFormDefinition = {
const height = (formData.get('height') || '').toString().trim();
const width = (formData.get('width') || '').toString().trim();
const updateNode = selectedNode || $createMediaNodeFromSrc(src);
updateNode.setSrc(src);
updateNode.setWidthAndHeight(width, height);
if (!selectedNode) {
$insertNodes([updateNode]);
// Update existing
if (selectedNode) {
selectedNode.setSrc(src);
selectedNode.setWidthAndHeight(width, height);
return;
}
// Insert new
const node = $createMediaNodeFromSrc(src);
if (width || height) {
node.setWidthAndHeight(width, height);
}
$insertNodes([node]);
});
return true;

View file

@ -1,6 +1,6 @@
import {
EditorFormDefinition,
EditorFormFieldDefinition,
EditorFormFieldDefinition, EditorFormFields,
EditorFormTabs,
EditorSelectFormFieldDefinition
} from "../../framework/forms";
@ -17,6 +17,8 @@ import {
import {formatSizeValue} from "../../../utils/dom";
import {TableCellNode, TableNode, TableRowNode} from "@lexical/table";
import {CommonBlockAlignment} from "lexical/nodes/common";
import {colorFieldBuilder} from "../../framework/blocks/color-field";
import {$addCaptionToTable, $isCaptionNode, $tableHasCaption} from "@lexical/table/LexicalCaptionNode";
const borderStyleInput: EditorSelectFormFieldDefinition = {
label: 'Border style',
@ -145,15 +147,15 @@ export const cellProperties: EditorFormDefinition = {
} as EditorSelectFormFieldDefinition,
];
const advancedFields: EditorFormFieldDefinition[] = [
const advancedFields: EditorFormFields = [
{
label: 'Border width', // inline-style: border-width
name: 'border_width',
type: 'text',
},
borderStyleInput, // inline-style: border-style
borderColorInput, // inline-style: border-color
backgroundColorInput, // inline-style: background-color
colorFieldBuilder(borderColorInput),
colorFieldBuilder(backgroundColorInput),
];
return new EditorFormTabs([
@ -210,14 +212,15 @@ export const rowProperties: EditorFormDefinition = {
type: 'text',
},
borderStyleInput, // style on tr: height
borderColorInput, // style on tr: height
backgroundColorInput, // style on tr: height
colorFieldBuilder(borderColorInput),
colorFieldBuilder(backgroundColorInput),
],
};
export function $showTablePropertiesForm(table: TableNode, context: EditorUiContext): EditorFormModal {
const styles = table.getStyles();
const modalForm = context.manager.createModal('table_properties');
modalForm.show({
width: styles.get('width') || '',
height: styles.get('height') || '',
@ -227,7 +230,7 @@ export function $showTablePropertiesForm(table: TableNode, context: EditorUiCont
border_style: styles.get('border-style') || '',
border_color: styles.get('border-color') || '',
background_color: styles.get('background-color') || '',
// caption: '', TODO
caption: $tableHasCaption(table) ? 'true' : '',
align: table.getAlignment(),
});
return modalForm;
@ -264,7 +267,17 @@ export const tableProperties: EditorFormDefinition = {
});
}
// TODO - cell caption
const showCaption = Boolean(formData.get('caption')?.toString() || '');
const hasCaption = $tableHasCaption(table);
if (showCaption && !hasCaption) {
$addCaptionToTable(table, context.translate('Caption'));
} else if (!showCaption && hasCaption) {
for (const child of table.getChildren()) {
if ($isCaptionNode(child)) {
child.remove();
}
}
}
});
return true;
},
@ -298,17 +311,17 @@ export const tableProperties: EditorFormDefinition = {
type: 'text',
},
{
label: 'caption', // Caption element
label: 'Show caption', // Caption element
name: 'caption',
type: 'text', // TODO -
type: 'checkbox',
},
alignmentInput, // alignment class
];
const advancedFields: EditorFormFieldDefinition[] = [
borderStyleInput, // Style - border-style
borderColorInput, // Style - border-color
backgroundColorInput, // Style - background-color
const advancedFields: EditorFormFields = [
borderStyleInput,
colorFieldBuilder(borderColorInput),
colorFieldBuilder(backgroundColorInput),
];
return new EditorFormTabs([

View file

@ -44,11 +44,11 @@ import {
} from "./buttons/block-formats";
import {
bold, clearFormating, code,
highlightColor,
highlightColor, highlightColorAction,
italic,
strikethrough, subscript,
superscript,
textColor,
textColor, textColorAction,
underline
} from "./buttons/inline-formats";
import {
@ -114,10 +114,10 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
new EditorButton(italic),
new EditorButton(underline),
new EditorDropdownButton({ button: new EditorColorButton(textColor, 'color') }, [
new EditorColorPicker('color'),
new EditorColorPicker(textColorAction),
]),
new EditorDropdownButton({button: new EditorColorButton(highlightColor, 'background-color')}, [
new EditorColorPicker('background-color'),
new EditorColorPicker(highlightColorAction),
]),
new EditorButton(strikethrough),
new EditorButton(superscript),

View file

@ -0,0 +1,56 @@
import {EditorContainerUiElement, EditorUiBuilderDefinition, EditorUiContext} from "../core";
import {EditorFormField, EditorFormFieldDefinition} from "../forms";
import {EditorColorPicker} from "./color-picker";
import {EditorDropdownButton} from "./dropdown-button";
import colorDisplayIcon from "@icons/editor/color-display.svg"
export class EditorColorField extends EditorContainerUiElement {
protected input: EditorFormField;
protected pickerButton: EditorDropdownButton;
constructor(input: EditorFormField) {
super([]);
this.input = input;
this.pickerButton = new EditorDropdownButton({
button: { icon: colorDisplayIcon, label: 'Select color'}
}, [
new EditorColorPicker(this.onColorSelect.bind(this))
]);
this.addChildren(this.pickerButton, this.input);
}
protected buildDOM(): HTMLElement {
const dom = this.input.getDOMElement();
dom.append(this.pickerButton.getDOMElement());
dom.classList.add('editor-color-field-container');
const field = dom.querySelector('input') as HTMLInputElement;
field.addEventListener('change', () => {
this.setIconColor(field.value);
});
return dom;
}
onColorSelect(color: string, context: EditorUiContext): void {
this.input.setValue(color);
}
setIconColor(color: string) {
const icon = this.getDOMElement().querySelector('svg .editor-icon-color-display');
if (icon) {
icon.setAttribute('fill', color || 'url(#pattern2)');
}
}
}
export function colorFieldBuilder(field: EditorFormFieldDefinition): EditorUiBuilderDefinition {
return {
build() {
return new EditorColorField(new EditorFormField(field));
}
}
}

View file

@ -1,9 +1,9 @@
import {EditorUiElement} from "../core";
import {$getSelection} from "lexical";
import {$patchStyleText} from "@lexical/selection";
import {EditorUiContext, EditorUiElement} from "../core";
import {el} from "../../../utils/dom";
import removeIcon from "@icons/editor/color-clear.svg";
import selectIcon from "@icons/editor/color-select.svg";
import {uniqueIdSmall} from "../../../../services/util";
const colorChoices = [
'#000000',
@ -34,18 +34,24 @@ const colorChoices = [
'#34495E',
];
const storageKey = 'bs-lexical-custom-colors';
export type EditorColorPickerCallback = (color: string, context: EditorUiContext) => void;
export class EditorColorPicker extends EditorUiElement {
protected styleProperty: string;
protected callback: EditorColorPickerCallback;
constructor(styleProperty: string) {
constructor(callback: EditorColorPickerCallback) {
super();
this.styleProperty = styleProperty;
this.callback = callback;
}
buildDOM(): HTMLElement {
const id = uniqueIdSmall();
const colorOptions = colorChoices.map(choice => {
const allChoices = [...colorChoices, ...this.getCustomColorChoices()];
const colorOptions = allChoices.map(choice => {
return el('div', {
class: 'editor-color-select-option',
style: `background-color: ${choice}`,
@ -57,11 +63,30 @@ export class EditorColorPicker extends EditorUiElement {
const removeButton = el('div', {
class: 'editor-color-select-option',
'data-color': '',
title: 'Clear color',
title: this.getContext().translate('Remove color'),
}, []);
removeButton.innerHTML = removeIcon;
colorOptions.push(removeButton);
const selectButton = el('label', {
class: 'editor-color-select-option',
for: `color-select-${id}`,
'data-color': '',
title: this.getContext().translate('Custom color'),
}, []);
selectButton.innerHTML = selectIcon;
colorOptions.push(selectButton);
const input = el('input', {type: 'color', hidden: 'true', id: `color-select-${id}`}) as HTMLInputElement;
colorOptions.push(input);
input.addEventListener('change', e => {
if (input.value) {
this.storeCustomColorChoice(input.value);
this.setColor(input.value);
this.rebuildDOM();
}
});
const colorRows = [];
for (let i = 0; i < colorOptions.length; i+=5) {
const options = colorOptions.slice(i, i + 5);
@ -79,16 +104,33 @@ export class EditorColorPicker extends EditorUiElement {
return wrapper;
}
storeCustomColorChoice(color: string) {
if (colorChoices.includes(color)) {
return;
}
const customColors: string[] = this.getCustomColorChoices();
if (customColors.includes(color)) {
return;
}
customColors.push(color);
window.localStorage.setItem(storageKey, JSON.stringify(customColors));
}
getCustomColorChoices(): string[] {
return JSON.parse(window.localStorage.getItem(storageKey) || '[]');
}
onClick(event: MouseEvent) {
const colorEl = (event.target as HTMLElement).closest('[data-color]') as HTMLElement;
if (!colorEl) return;
const color = colorEl.dataset.color as string;
this.getContext().editor.update(() => {
const selection = $getSelection();
if (selection) {
$patchStyleText(selection, {[this.styleProperty]: color || null});
}
});
this.setColor(color);
}
setColor(color: string) {
this.callback(color, this.getContext());
}
}

View file

@ -44,7 +44,6 @@ export class LinkField extends EditorContainerUiElement {
updateFormFromHeader(header: HeadingNode) {
this.getHeaderIdAndText(header).then(({id, text}) => {
console.log('updating form', id, text);
const modal = this.getContext().manager.getActiveModal('link');
if (modal) {
modal.getForm().setValues({
@ -60,7 +59,6 @@ export class LinkField extends EditorContainerUiElement {
return new Promise((res) => {
this.getContext().editor.update(() => {
let id = header.getId();
console.log('header', id, header.__id);
if (!id) {
id = 'header-' + uniqueIdSmall();
header.setId(id);

View file

@ -53,6 +53,13 @@ export abstract class EditorUiElement {
return this.dom;
}
rebuildDOM(): HTMLElement {
const newDOM = this.buildDOM();
this.dom?.replaceWith(newDOM);
this.dom = newDOM;
return this.dom;
}
trans(text: string) {
return this.getContext().translate(text);
}

View file

@ -11,7 +11,7 @@ import {el} from "../../utils/dom";
export interface EditorFormFieldDefinition {
label: string;
name: string;
type: 'text' | 'select' | 'textarea';
type: 'text' | 'select' | 'textarea' | 'checkbox';
}
export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition {
@ -19,15 +19,17 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti
valuesByLabel: Record<string, string>
}
export type EditorFormFields = (EditorFormFieldDefinition|EditorUiBuilderDefinition)[];
interface EditorFormTabDefinition {
label: string;
contents: EditorFormFieldDefinition[];
contents: EditorFormFields;
}
export interface EditorFormDefinition {
submitText: string;
action: (formData: FormData, context: EditorUiContext) => Promise<boolean>;
fields: (EditorFormFieldDefinition|EditorUiBuilderDefinition)[];
fields: EditorFormFields;
}
export class EditorFormField extends EditorUiElement {
@ -40,7 +42,12 @@ export class EditorFormField extends EditorUiElement {
setValue(value: string) {
const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement;
input.value = value;
if (this.definition.type === 'checkbox') {
input.checked = Boolean(value);
} else {
input.value = value;
}
input.dispatchEvent(new Event('change'));
}
getName(): string {
@ -58,6 +65,8 @@ export class EditorFormField extends EditorUiElement {
input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems);
} else if (this.definition.type === 'textarea') {
input = el('textarea', {id, name: this.definition.name, class: 'editor-form-field-input'});
} else if (this.definition.type === 'checkbox') {
input = el('input', {id, name: this.definition.name, type: 'checkbox', class: 'editor-form-field-input-checkbox', value: 'true'});
} else {
input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'});
}
@ -155,11 +164,17 @@ export class EditorForm extends EditorContainerUiElement {
export class EditorFormTab extends EditorContainerUiElement {
protected definition: EditorFormTabDefinition;
protected fields: EditorFormField[];
protected fields: EditorUiElement[];
protected id: string;
constructor(definition: EditorFormTabDefinition) {
const fields = definition.contents.map(fieldDef => new EditorFormField(fieldDef));
const fields = definition.contents.map(fieldDef => {
if (isUiBuilderDefinition(fieldDef)) {
return fieldDef.build();
}
return new EditorFormField(fieldDef)
});
super(fields);
this.definition = definition;

View file

@ -649,6 +649,16 @@ textarea.editor-form-field-input {
width: $inputWidth - 40px;
}
}
.editor-color-field-container {
position: relative;
input {
padding-left: 36px;
}
.editor-dropdown-menu-container {
position: absolute;
bottom: 0;
}
}
// Editor theme styles
.editor-theme-bold {