'superscript' => 'Superscript',
'subscript' => 'Subscript',
'text_color' => 'Text color',
+ 'highlight_color' => 'Highlight color',
'custom_color' => 'Custom color',
'remove_color' => 'Remove color',
'background_color' => 'Background color',
import {modals} from "./ui/defaults/modals";
import {CodeBlockDecorator} from "./ui/decorators/code-block";
import {DiagramDecorator} from "./ui/decorators/diagram";
+import {registerMouseHandling} from "./services/mouse-handling";
const theme = {
text: {
registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(context),
registerKeyboardHandling(context),
+ registerMouseHandling(context),
registerTableResizer(editor, context.scrollDOM),
registerTableSelectionHandler(editor),
registerTaskListHandler(editor, context.editorDOM),
dispatchKeydownEventForNode(node, editor, key);
}
});
+}
+
+export function dispatchEditorMouseClick(editor: LexicalEditor, clientX: number, clientY: number) {
+ const dom = editor.getRootElement();
+ if (!dom) {
+ return;
+ }
+
+ const event = new MouseEvent('click', {
+ clientX: clientX,
+ clientY: clientY,
+ bubbles: true,
+ cancelable: true,
+ });
+ dom?.dispatchEvent(event);
+ editor.commitUpdates();
}
\ No newline at end of file
});
expect(html).toBe(
- '<p>Hello</p><p>World</p>',
+ '<p>Hello</p>\n<p>World</p>',
);
});
$appendNodesToHTML(editor, topLevelNode, container, selection);
}
- return container.innerHTML;
+ const nodeCode = [];
+ for (const node of container.childNodes) {
+ if ("outerHTML" in node) {
+ nodeCode.push(node.outerHTML)
+ } else {
+ const wrap = document.createElement('div');
+ wrap.appendChild(node.cloneNode(true));
+ nodeCode.push(wrap.innerHTML);
+ }
+ }
+
+ return nodeCode.join('\n');
}
function $appendNodesToHTML(
});
});
- test('AutoLinkNode.createDOM() sanitizes javascript: URLs', async () => {
- const {editor} = testEnv;
-
- await editor.update(() => {
- // eslint-disable-next-line no-script-url
- const autoLinkNode = new AutoLinkNode('javascript:alert(0)');
- expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
- '<a href="about:blank" class="my-autolink-class"></a>',
- );
- });
- });
-
test('AutoLinkNode.updateDOM()', async () => {
const {editor} = testEnv;
});
});
- test('LinkNode.createDOM() sanitizes javascript: URLs', async () => {
- const {editor} = testEnv;
-
- await editor.update(() => {
- // eslint-disable-next-line no-script-url
- const linkNode = new LinkNode('javascript:alert(0)');
- expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
- '<a href="about:blank" class="my-link-class"></a>',
- );
- });
- });
-
test('LinkNode.updateDOM()', async () => {
const {editor} = testEnv;
type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
-const SUPPORTED_URL_PROTOCOLS = new Set([
- 'http:',
- 'https:',
- 'mailto:',
- 'sms:',
- 'tel:',
-]);
-
/** @noInheritDoc */
export class LinkNode extends ElementNode {
/** @internal */
createDOM(config: EditorConfig): LinkHTMLElementType {
const element = document.createElement('a');
- element.href = this.sanitizeUrl(this.__url);
+ element.href = this.__url;
if (this.__target !== null) {
element.target = this.__target;
}
return node;
}
- sanitizeUrl(url: string): string {
- try {
- const parsedUrl = new URL(url);
- // eslint-disable-next-line no-script-url
- if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
- return 'about:blank';
- }
- } catch {
- return url;
- }
- return url;
- }
-
exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
return {
...super.exportJSON(),
const hasUnderlineTextDecoration = textDecoration.includes('underline');
if (domNode instanceof HTMLElement) {
- tableCellNode.setStyles(extractStyleMapFromElement(domNode));
+ const styleMap = extractStyleMapFromElement(domNode);
+ styleMap.delete('background-color');
+ tableCellNode.setStyles(styleMap);
tableCellNode.setAlignment(extractAlignmentFromElement(domNode));
}
+ const background = style.backgroundColor || null;
+ if (background) {
+ tableCellNode.setBackgroundColor(background);
+ }
+
return {
after: (childLexicalNodes) => {
if (childLexicalNodes.length === 0) {
{
_: 'split paragraph in between two text nodes',
expectedHtml:
- '<p>Hello</p><p>world</p>',
+ '<p>Hello</p>\n<p>world</p>',
initialHtml: '<p><span>Hello</span><span>world</span></p>',
splitOffset: 1,
splitPath: [0],
{
_: 'split paragraph before the first text node',
expectedHtml:
- '<p><br></p><p>Helloworld</p>',
+ '<p><br></p>\n<p>Helloworld</p>',
initialHtml: '<p><span>Hello</span><span>world</span></p>',
splitOffset: 0,
splitPath: [0],
{
_: 'split paragraph after the last text node',
expectedHtml:
- '<p>Helloworld</p><p><br></p>',
+ '<p>Helloworld</p>\n<p><br></p>',
initialHtml: '<p><span>Hello</span><span>world</span></p>',
splitOffset: 2, // Any offset that is higher than children size
splitPath: [0],
{
_: 'split list items between two text nodes',
expectedHtml:
- '<ul><li>Hello</li></ul>' +
+ '<ul><li>Hello</li></ul>\n' +
'<ul><li>world</li></ul>',
initialHtml: '<ul><li><span>Hello</span><span>world</span></li></ul>',
splitOffset: 1, // Any offset that is higher than children size
{
_: 'split list items before the first text node',
expectedHtml:
- '<ul><li></li></ul>' +
+ '<ul><li></li></ul>\n' +
'<ul><li>Helloworld</li></ul>',
initialHtml: '<ul><li><span>Hello</span><span>world</span></li></ul>',
splitOffset: 0, // Any offset that is higher than children size
'<ul>' +
'<li>Before</li>' +
'<li style="list-style: none;"><ul><li>Hello</li></ul></li>' +
- '</ul>' +
+ '</ul>\n' +
'<ul>' +
'<li style="list-style: none;"><ul><li>world</li></ul></li>' +
'<li>After</li>' +
{
_: 'insert into paragraph in between two text nodes',
expectedHtml:
- '<p>Hello</p><test-decorator></test-decorator><p>world</p>',
+ '<p>Hello</p>\n<test-decorator></test-decorator>\n<p>world</p>',
initialHtml: '<p><span>Helloworld</span></p>',
selectionOffset: 5, // Selection on text node after "Hello" world
selectionPath: [0, 0],
'<ul>' +
'<li>Before</li>' +
'<li style="list-style: none;"><ul><li>Hello</li></ul></li>' +
- '</ul>' +
- '<test-decorator></test-decorator>' +
+ '</ul>\n' +
+ '<test-decorator></test-decorator>\n' +
'<ul>' +
'<li style="list-style: none;"><ul><li>world</li></ul></li>' +
'<li>After</li>' +
},
{
_: 'insert into empty paragraph',
- expectedHtml: '<p><br></p><test-decorator></test-decorator><p><br></p>',
+ expectedHtml: '<p><br></p>\n<test-decorator></test-decorator>\n<p><br></p>',
initialHtml: '<p></p>',
selectionOffset: 0, // Selection on text node after "Hello" world
selectionPath: [0],
{
_: 'insert in the end of paragraph',
expectedHtml:
- '<p>Hello world</p>' +
- '<test-decorator></test-decorator>' +
+ '<p>Hello world</p>\n' +
+ '<test-decorator></test-decorator>\n' +
'<p><br></p>',
initialHtml: '<p>Hello world</p>',
selectionOffset: 12, // Selection on text node after "Hello" world
{
_: 'insert in the beginning of paragraph',
expectedHtml:
- '<p><br></p>' +
- '<test-decorator></test-decorator>' +
+ '<p><br></p>\n' +
+ '<test-decorator></test-decorator>\n' +
'<p>Hello world</p>',
initialHtml: '<p>Hello world</p>',
selectionOffset: 0, // Selection on text node after "Hello" world
{
_: 'insert with selection on root start',
expectedHtml:
- '<test-decorator></test-decorator>' +
- '<test-decorator></test-decorator>' +
- '<p>Before</p>' +
+ '<test-decorator></test-decorator>\n' +
+ '<test-decorator></test-decorator>\n' +
+ '<p>Before</p>\n' +
'<p>After</p>',
initialHtml:
'<test-decorator></test-decorator>' +
{
_: 'insert with selection on root child',
expectedHtml:
- '<p>Before</p>' +
- '<test-decorator></test-decorator>' +
+ '<p>Before</p>\n' +
+ '<test-decorator></test-decorator>\n' +
'<p>After</p>',
initialHtml: '<p>Before</p><p>After</p>',
selectionOffset: 1,
{
_: 'insert with selection on root end',
expectedHtml:
- '<p>Before</p>' +
+ '<p>Before</p>\n' +
'<test-decorator></test-decorator>',
initialHtml: '<p>Before</p>',
selectionOffset: 1,
--- /dev/null
+import {
+ createTestContext, destroyFromContext, dispatchEditorMouseClick,
+} from "lexical/__tests__/utils";
+import {
+ $getRoot, LexicalEditor, LexicalNode,
+ ParagraphNode,
+} from "lexical";
+import {registerRichText} from "@lexical/rich-text";
+import {EditorUiContext} from "../../ui/framework/core";
+import {registerMouseHandling} from "../mouse-handling";
+import {$createTableNode, TableNode} from "@lexical/table";
+
+describe('Mouse-handling service tests', () => {
+
+ let context!: EditorUiContext;
+ let editor!: LexicalEditor;
+
+ beforeEach(() => {
+ context = createTestContext();
+ editor = context.editor;
+ registerRichText(editor);
+ registerMouseHandling(context);
+ });
+
+ afterEach(() => {
+ destroyFromContext(context);
+ });
+
+ test('Click below last table inserts new empty paragraph', () => {
+ let tableNode!: TableNode;
+ let lastRootChild!: LexicalNode|null;
+
+ editor.updateAndCommit(() => {
+ tableNode = $createTableNode();
+ $getRoot().append(tableNode);
+ lastRootChild = $getRoot().getLastChild();
+ });
+
+ expect(lastRootChild).toBeInstanceOf(TableNode);
+
+ const tableDOM = editor.getElementByKey(tableNode.getKey());
+ const rect = tableDOM?.getBoundingClientRect();
+ dispatchEditorMouseClick(editor, 0, (rect?.bottom || 0) + 1)
+
+ editor.getEditorState().read(() => {
+ lastRootChild = $getRoot().getLastChild();
+ });
+
+ expect(lastRootChild).toBeInstanceOf(ParagraphNode);
+ });
+});
\ No newline at end of file
--- /dev/null
+import {EditorUiContext} from "../ui/framework/core";
+import {
+ $createParagraphNode, $getRoot,
+ $getSelection,
+ $isDecoratorNode, CLICK_COMMAND,
+ COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND,
+ KEY_BACKSPACE_COMMAND,
+ KEY_DELETE_COMMAND,
+ KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
+ LexicalEditor,
+ LexicalNode
+} from "lexical";
+import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
+import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
+import {getLastSelection} from "../utils/selection";
+import {$getNearestNodeBlockParent, $getParentOfType, $selectOrCreateAdjacent} from "../utils/nodes";
+import {$setInsetForSelection} from "../utils/lists";
+import {$isListItemNode} from "@lexical/list";
+import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
+import {$isDiagramNode} from "../utils/diagrams";
+import {$isTableNode} from "@lexical/table";
+
+function isHardToEscapeNode(node: LexicalNode): boolean {
+ return $isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node) || $isTableNode(node);
+}
+
+function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boolean {
+ const lastNode = $getRoot().getLastChild();
+ if (!lastNode || !isHardToEscapeNode(lastNode)) {
+ return false;
+ }
+
+ const lastNodeDom = context.editor.getElementByKey(lastNode.getKey());
+ if (!lastNodeDom) {
+ return false;
+ }
+
+ const nodeBounds = lastNodeDom.getBoundingClientRect();
+ const isClickBelow = event.clientY > nodeBounds.bottom;
+ if (isClickBelow) {
+ context.editor.update(() => {
+ const newNode = $createParagraphNode();
+ $getRoot().append(newNode);
+ newNode.select();
+ });
+ return true;
+ }
+
+ return false;
+}
+
+
+export function registerMouseHandling(context: EditorUiContext): () => void {
+ const unregisterClick = context.editor.registerCommand(CLICK_COMMAND, (event): boolean => {
+ insertBelowLastNode(context, event);
+ return false;
+ }, COMMAND_PRIORITY_LOW);
+
+
+ return () => {
+ unregisterClick();
+ };
+}
\ No newline at end of file
import {showLinkSelector} from "../utils/links";
import {HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode";
-function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean {
- toggleSelectionAsHeading(editor, tag);
+function headerHandler(context: EditorUiContext, tag: HeadingTagType): boolean {
+ toggleSelectionAsHeading(context.editor, tag);
+ context.manager.triggerFutureStateRefresh();
return true;
}
function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction {
- return (editor: LexicalEditor) => {
+ return (editor: LexicalEditor, context: EditorUiContext) => {
formatAction(editor);
+ context.manager.triggerFutureStateRefresh();
return true;
};
}
window.$events.emit('editor-save-page');
return true;
},
- 'meta+1': (editor) => headerHandler(editor, 'h1'),
- 'meta+2': (editor) => headerHandler(editor, 'h2'),
- 'meta+3': (editor) => headerHandler(editor, 'h3'),
- 'meta+4': (editor) => headerHandler(editor, 'h4'),
+ 'meta+1': (editor, context) => headerHandler(context, 'h2'),
+ 'meta+2': (editor, context) => headerHandler(context, 'h3'),
+ 'meta+3': (editor, context) => headerHandler(context, 'h4'),
+ 'meta+4': (editor, context) => headerHandler(context, 'h5'),
'meta+5': wrapFormatAction(toggleSelectionAsParagraph),
'meta+d': wrapFormatAction(toggleSelectionAsParagraph),
'meta+6': wrapFormatAction(toggleSelectionAsBlockquote),
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 {
export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon);
export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon);
export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon};
-export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon};
+export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color', icon: highlightIcon};
function colorAction(context: EditorUiContext, property: string, color: string): void {
context.editor.update(() => {
}
export const textColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color);
-export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color);
+export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'background-color', color);
export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon);
export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon);
border_width: styles.get('border-width') || '',
border_style: styles.get('border-style') || '',
border_color: styles.get('border-color') || '',
- background_color: styles.get('background-color') || '',
+ background_color: cell.getBackgroundColor() || styles.get('background-color') || '',
});
return modalForm;
}
$setTableCellColumnWidth(cell, width);
cell.updateTag(formData.get('type')?.toString() || '');
cell.setAlignment((formData.get('h_align')?.toString() || '') as CommonBlockAlignment);
+ cell.setBackgroundColor(formData.get('background_color')?.toString() || '');
const styles = cell.getStyles();
styles.set('height', formatSizeValue(formData.get('height')?.toString() || ''));
styles.set('border-width', formatSizeValue(formData.get('border_width')?.toString() || ''));
styles.set('border-style', formData.get('border_style')?.toString() || '');
styles.set('border-color', formData.get('border_color')?.toString() || '');
- styles.set('background-color', formData.get('background_color')?.toString() || '');
cell.setStyles(styles);
}
const cells = row.getChildren().filter(c => $isTableCellNode(c));
for (const cell of cells) {
cell.setStyles(new Map);
+ cell.setBackgroundColor(null);
cell.clearWidth();
}
}
}
}
+// Specific field styles
+textarea.editor-form-field-input[name="source"] {
+ width: 1000px;
+ height: 600px;
+ max-height: 60vh;
+ max-width: 80vw;
+}
+
// Editor theme styles
.editor-theme-bold {
font-weight: bold;