mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-27 06:01:54 +03:00
Merge pull request #5731 from BookStackApp/lexical_jul25
New WYSIWYG editor changes for July 2025
This commit is contained in:
@ -48,6 +48,7 @@ return [
|
|||||||
'superscript' => 'Superscript',
|
'superscript' => 'Superscript',
|
||||||
'subscript' => 'Subscript',
|
'subscript' => 'Subscript',
|
||||||
'text_color' => 'Text color',
|
'text_color' => 'Text color',
|
||||||
|
'highlight_color' => 'Highlight color',
|
||||||
'custom_color' => 'Custom color',
|
'custom_color' => 'Custom color',
|
||||||
'remove_color' => 'Remove color',
|
'remove_color' => 'Remove color',
|
||||||
'background_color' => 'Background color',
|
'background_color' => 'Background color',
|
||||||
|
@ -19,6 +19,7 @@ import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "
|
|||||||
import {modals} from "./ui/defaults/modals";
|
import {modals} from "./ui/defaults/modals";
|
||||||
import {CodeBlockDecorator} from "./ui/decorators/code-block";
|
import {CodeBlockDecorator} from "./ui/decorators/code-block";
|
||||||
import {DiagramDecorator} from "./ui/decorators/diagram";
|
import {DiagramDecorator} from "./ui/decorators/diagram";
|
||||||
|
import {registerMouseHandling} from "./services/mouse-handling";
|
||||||
|
|
||||||
const theme = {
|
const theme = {
|
||||||
text: {
|
text: {
|
||||||
@ -51,6 +52,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
|
|||||||
registerHistory(editor, createEmptyHistoryState(), 300),
|
registerHistory(editor, createEmptyHistoryState(), 300),
|
||||||
registerShortcuts(context),
|
registerShortcuts(context),
|
||||||
registerKeyboardHandling(context),
|
registerKeyboardHandling(context),
|
||||||
|
registerMouseHandling(context),
|
||||||
registerTableResizer(editor, context.scrollDOM),
|
registerTableResizer(editor, context.scrollDOM),
|
||||||
registerTableSelectionHandler(editor),
|
registerTableSelectionHandler(editor),
|
||||||
registerTaskListHandler(editor, context.editorDOM),
|
registerTaskListHandler(editor, context.editorDOM),
|
||||||
|
@ -848,4 +848,20 @@ export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key:
|
|||||||
dispatchKeydownEventForNode(node, editor, key);
|
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();
|
||||||
}
|
}
|
@ -146,7 +146,7 @@ describe('HTML', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(html).toBe(
|
expect(html).toBe(
|
||||||
'<p>Hello</p><p>World</p>',
|
'<p>Hello</p>\n<p>World</p>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -85,7 +85,18 @@ export function $generateHtmlFromNodes(
|
|||||||
$appendNodesToHTML(editor, topLevelNode, container, selection);
|
$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(
|
function $appendNodesToHTML(
|
||||||
|
@ -273,18 +273,6 @@ describe('LexicalAutoAutoLinkNode tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
test('AutoLinkNode.updateDOM()', async () => {
|
||||||
const {editor} = testEnv;
|
const {editor} = testEnv;
|
||||||
|
|
||||||
|
@ -218,18 +218,6 @@ describe('LexicalLinkNode tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
test('LinkNode.updateDOM()', async () => {
|
||||||
const {editor} = testEnv;
|
const {editor} = testEnv;
|
||||||
|
|
||||||
|
@ -48,14 +48,6 @@ export type SerializedLinkNode = Spread<
|
|||||||
|
|
||||||
type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
|
type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
|
||||||
|
|
||||||
const SUPPORTED_URL_PROTOCOLS = new Set([
|
|
||||||
'http:',
|
|
||||||
'https:',
|
|
||||||
'mailto:',
|
|
||||||
'sms:',
|
|
||||||
'tel:',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** @noInheritDoc */
|
/** @noInheritDoc */
|
||||||
export class LinkNode extends ElementNode {
|
export class LinkNode extends ElementNode {
|
||||||
/** @internal */
|
/** @internal */
|
||||||
@ -90,7 +82,7 @@ export class LinkNode extends ElementNode {
|
|||||||
|
|
||||||
createDOM(config: EditorConfig): LinkHTMLElementType {
|
createDOM(config: EditorConfig): LinkHTMLElementType {
|
||||||
const element = document.createElement('a');
|
const element = document.createElement('a');
|
||||||
element.href = this.sanitizeUrl(this.__url);
|
element.href = this.__url;
|
||||||
if (this.__target !== null) {
|
if (this.__target !== null) {
|
||||||
element.target = this.__target;
|
element.target = this.__target;
|
||||||
}
|
}
|
||||||
@ -166,19 +158,6 @@ export class LinkNode extends ElementNode {
|
|||||||
return node;
|
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 {
|
exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
|
||||||
return {
|
return {
|
||||||
...super.exportJSON(),
|
...super.exportJSON(),
|
||||||
|
@ -353,10 +353,17 @@ export function $convertTableCellNodeElement(
|
|||||||
const hasUnderlineTextDecoration = textDecoration.includes('underline');
|
const hasUnderlineTextDecoration = textDecoration.includes('underline');
|
||||||
|
|
||||||
if (domNode instanceof HTMLElement) {
|
if (domNode instanceof HTMLElement) {
|
||||||
tableCellNode.setStyles(extractStyleMapFromElement(domNode));
|
const styleMap = extractStyleMapFromElement(domNode);
|
||||||
|
styleMap.delete('background-color');
|
||||||
|
tableCellNode.setStyles(styleMap);
|
||||||
tableCellNode.setAlignment(extractAlignmentFromElement(domNode));
|
tableCellNode.setAlignment(extractAlignmentFromElement(domNode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const background = style.backgroundColor || null;
|
||||||
|
if (background) {
|
||||||
|
tableCellNode.setBackgroundColor(background);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
after: (childLexicalNodes) => {
|
after: (childLexicalNodes) => {
|
||||||
if (childLexicalNodes.length === 0) {
|
if (childLexicalNodes.length === 0) {
|
||||||
|
@ -38,7 +38,7 @@ describe('LexicalUtils#splitNode', () => {
|
|||||||
{
|
{
|
||||||
_: 'split paragraph in between two text nodes',
|
_: 'split paragraph in between two text nodes',
|
||||||
expectedHtml:
|
expectedHtml:
|
||||||
'<p>Hello</p><p>world</p>',
|
'<p>Hello</p>\n<p>world</p>',
|
||||||
initialHtml: '<p><span>Hello</span><span>world</span></p>',
|
initialHtml: '<p><span>Hello</span><span>world</span></p>',
|
||||||
splitOffset: 1,
|
splitOffset: 1,
|
||||||
splitPath: [0],
|
splitPath: [0],
|
||||||
@ -46,7 +46,7 @@ describe('LexicalUtils#splitNode', () => {
|
|||||||
{
|
{
|
||||||
_: 'split paragraph before the first text node',
|
_: 'split paragraph before the first text node',
|
||||||
expectedHtml:
|
expectedHtml:
|
||||||
'<p><br></p><p>Helloworld</p>',
|
'<p><br></p>\n<p>Helloworld</p>',
|
||||||
initialHtml: '<p><span>Hello</span><span>world</span></p>',
|
initialHtml: '<p><span>Hello</span><span>world</span></p>',
|
||||||
splitOffset: 0,
|
splitOffset: 0,
|
||||||
splitPath: [0],
|
splitPath: [0],
|
||||||
@ -54,7 +54,7 @@ describe('LexicalUtils#splitNode', () => {
|
|||||||
{
|
{
|
||||||
_: 'split paragraph after the last text node',
|
_: 'split paragraph after the last text node',
|
||||||
expectedHtml:
|
expectedHtml:
|
||||||
'<p>Helloworld</p><p><br></p>',
|
'<p>Helloworld</p>\n<p><br></p>',
|
||||||
initialHtml: '<p><span>Hello</span><span>world</span></p>',
|
initialHtml: '<p><span>Hello</span><span>world</span></p>',
|
||||||
splitOffset: 2, // Any offset that is higher than children size
|
splitOffset: 2, // Any offset that is higher than children size
|
||||||
splitPath: [0],
|
splitPath: [0],
|
||||||
@ -62,7 +62,7 @@ describe('LexicalUtils#splitNode', () => {
|
|||||||
{
|
{
|
||||||
_: 'split list items between two text nodes',
|
_: 'split list items between two text nodes',
|
||||||
expectedHtml:
|
expectedHtml:
|
||||||
'<ul><li>Hello</li></ul>' +
|
'<ul><li>Hello</li></ul>\n' +
|
||||||
'<ul><li>world</li></ul>',
|
'<ul><li>world</li></ul>',
|
||||||
initialHtml: '<ul><li><span>Hello</span><span>world</span></li></ul>',
|
initialHtml: '<ul><li><span>Hello</span><span>world</span></li></ul>',
|
||||||
splitOffset: 1, // Any offset that is higher than children size
|
splitOffset: 1, // Any offset that is higher than children size
|
||||||
@ -71,7 +71,7 @@ describe('LexicalUtils#splitNode', () => {
|
|||||||
{
|
{
|
||||||
_: 'split list items before the first text node',
|
_: 'split list items before the first text node',
|
||||||
expectedHtml:
|
expectedHtml:
|
||||||
'<ul><li></li></ul>' +
|
'<ul><li></li></ul>\n' +
|
||||||
'<ul><li>Helloworld</li></ul>',
|
'<ul><li>Helloworld</li></ul>',
|
||||||
initialHtml: '<ul><li><span>Hello</span><span>world</span></li></ul>',
|
initialHtml: '<ul><li><span>Hello</span><span>world</span></li></ul>',
|
||||||
splitOffset: 0, // Any offset that is higher than children size
|
splitOffset: 0, // Any offset that is higher than children size
|
||||||
@ -83,7 +83,7 @@ describe('LexicalUtils#splitNode', () => {
|
|||||||
'<ul>' +
|
'<ul>' +
|
||||||
'<li>Before</li>' +
|
'<li>Before</li>' +
|
||||||
'<li style="list-style: none;"><ul><li>Hello</li></ul></li>' +
|
'<li style="list-style: none;"><ul><li>Hello</li></ul></li>' +
|
||||||
'</ul>' +
|
'</ul>\n' +
|
||||||
'<ul>' +
|
'<ul>' +
|
||||||
'<li style="list-style: none;"><ul><li>world</li></ul></li>' +
|
'<li style="list-style: none;"><ul><li>world</li></ul></li>' +
|
||||||
'<li>After</li>' +
|
'<li>After</li>' +
|
||||||
|
@ -46,7 +46,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
|
|||||||
{
|
{
|
||||||
_: 'insert into paragraph in between two text nodes',
|
_: 'insert into paragraph in between two text nodes',
|
||||||
expectedHtml:
|
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>',
|
initialHtml: '<p><span>Helloworld</span></p>',
|
||||||
selectionOffset: 5, // Selection on text node after "Hello" world
|
selectionOffset: 5, // Selection on text node after "Hello" world
|
||||||
selectionPath: [0, 0],
|
selectionPath: [0, 0],
|
||||||
@ -57,8 +57,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
|
|||||||
'<ul>' +
|
'<ul>' +
|
||||||
'<li>Before</li>' +
|
'<li>Before</li>' +
|
||||||
'<li style="list-style: none;"><ul><li>Hello</li></ul></li>' +
|
'<li style="list-style: none;"><ul><li>Hello</li></ul></li>' +
|
||||||
'</ul>' +
|
'</ul>\n' +
|
||||||
'<test-decorator></test-decorator>' +
|
'<test-decorator></test-decorator>\n' +
|
||||||
'<ul>' +
|
'<ul>' +
|
||||||
'<li style="list-style: none;"><ul><li>world</li></ul></li>' +
|
'<li style="list-style: none;"><ul><li>world</li></ul></li>' +
|
||||||
'<li>After</li>' +
|
'<li>After</li>' +
|
||||||
@ -74,7 +74,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
_: 'insert into empty paragraph',
|
_: '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>',
|
initialHtml: '<p></p>',
|
||||||
selectionOffset: 0, // Selection on text node after "Hello" world
|
selectionOffset: 0, // Selection on text node after "Hello" world
|
||||||
selectionPath: [0],
|
selectionPath: [0],
|
||||||
@ -82,8 +82,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
|
|||||||
{
|
{
|
||||||
_: 'insert in the end of paragraph',
|
_: 'insert in the end of paragraph',
|
||||||
expectedHtml:
|
expectedHtml:
|
||||||
'<p>Hello world</p>' +
|
'<p>Hello world</p>\n' +
|
||||||
'<test-decorator></test-decorator>' +
|
'<test-decorator></test-decorator>\n' +
|
||||||
'<p><br></p>',
|
'<p><br></p>',
|
||||||
initialHtml: '<p>Hello world</p>',
|
initialHtml: '<p>Hello world</p>',
|
||||||
selectionOffset: 12, // Selection on text node after "Hello" world
|
selectionOffset: 12, // Selection on text node after "Hello" world
|
||||||
@ -92,8 +92,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
|
|||||||
{
|
{
|
||||||
_: 'insert in the beginning of paragraph',
|
_: 'insert in the beginning of paragraph',
|
||||||
expectedHtml:
|
expectedHtml:
|
||||||
'<p><br></p>' +
|
'<p><br></p>\n' +
|
||||||
'<test-decorator></test-decorator>' +
|
'<test-decorator></test-decorator>\n' +
|
||||||
'<p>Hello world</p>',
|
'<p>Hello world</p>',
|
||||||
initialHtml: '<p>Hello world</p>',
|
initialHtml: '<p>Hello world</p>',
|
||||||
selectionOffset: 0, // Selection on text node after "Hello" world
|
selectionOffset: 0, // Selection on text node after "Hello" world
|
||||||
@ -102,9 +102,9 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
|
|||||||
{
|
{
|
||||||
_: 'insert with selection on root start',
|
_: 'insert with selection on root start',
|
||||||
expectedHtml:
|
expectedHtml:
|
||||||
'<test-decorator></test-decorator>' +
|
'<test-decorator></test-decorator>\n' +
|
||||||
'<test-decorator></test-decorator>' +
|
'<test-decorator></test-decorator>\n' +
|
||||||
'<p>Before</p>' +
|
'<p>Before</p>\n' +
|
||||||
'<p>After</p>',
|
'<p>After</p>',
|
||||||
initialHtml:
|
initialHtml:
|
||||||
'<test-decorator></test-decorator>' +
|
'<test-decorator></test-decorator>' +
|
||||||
@ -116,8 +116,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
|
|||||||
{
|
{
|
||||||
_: 'insert with selection on root child',
|
_: 'insert with selection on root child',
|
||||||
expectedHtml:
|
expectedHtml:
|
||||||
'<p>Before</p>' +
|
'<p>Before</p>\n' +
|
||||||
'<test-decorator></test-decorator>' +
|
'<test-decorator></test-decorator>\n' +
|
||||||
'<p>After</p>',
|
'<p>After</p>',
|
||||||
initialHtml: '<p>Before</p><p>After</p>',
|
initialHtml: '<p>Before</p><p>After</p>',
|
||||||
selectionOffset: 1,
|
selectionOffset: 1,
|
||||||
@ -126,7 +126,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
|
|||||||
{
|
{
|
||||||
_: 'insert with selection on root end',
|
_: 'insert with selection on root end',
|
||||||
expectedHtml:
|
expectedHtml:
|
||||||
'<p>Before</p>' +
|
'<p>Before</p>\n' +
|
||||||
'<test-decorator></test-decorator>',
|
'<test-decorator></test-decorator>',
|
||||||
initialHtml: '<p>Before</p>',
|
initialHtml: '<p>Before</p>',
|
||||||
selectionOffset: 1,
|
selectionOffset: 1,
|
||||||
|
@ -0,0 +1,51 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
63
resources/js/wysiwyg/services/mouse-handling.ts
Normal file
63
resources/js/wysiwyg/services/mouse-handling.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
@ -13,14 +13,16 @@ import {$showLinkForm} from "../ui/defaults/forms/objects";
|
|||||||
import {showLinkSelector} from "../utils/links";
|
import {showLinkSelector} from "../utils/links";
|
||||||
import {HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode";
|
import {HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode";
|
||||||
|
|
||||||
function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean {
|
function headerHandler(context: EditorUiContext, tag: HeadingTagType): boolean {
|
||||||
toggleSelectionAsHeading(editor, tag);
|
toggleSelectionAsHeading(context.editor, tag);
|
||||||
|
context.manager.triggerFutureStateRefresh();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction {
|
function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction {
|
||||||
return (editor: LexicalEditor) => {
|
return (editor: LexicalEditor, context: EditorUiContext) => {
|
||||||
formatAction(editor);
|
formatAction(editor);
|
||||||
|
context.manager.triggerFutureStateRefresh();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -45,10 +47,10 @@ const actionsByKeys: Record<string, ShortcutAction> = {
|
|||||||
window.$events.emit('editor-save-page');
|
window.$events.emit('editor-save-page');
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
'meta+1': (editor) => headerHandler(editor, 'h1'),
|
'meta+1': (editor, context) => headerHandler(context, 'h2'),
|
||||||
'meta+2': (editor) => headerHandler(editor, 'h2'),
|
'meta+2': (editor, context) => headerHandler(context, 'h3'),
|
||||||
'meta+3': (editor) => headerHandler(editor, 'h3'),
|
'meta+3': (editor, context) => headerHandler(context, 'h4'),
|
||||||
'meta+4': (editor) => headerHandler(editor, 'h4'),
|
'meta+4': (editor, context) => headerHandler(context, 'h5'),
|
||||||
'meta+5': wrapFormatAction(toggleSelectionAsParagraph),
|
'meta+5': wrapFormatAction(toggleSelectionAsParagraph),
|
||||||
'meta+d': wrapFormatAction(toggleSelectionAsParagraph),
|
'meta+d': wrapFormatAction(toggleSelectionAsParagraph),
|
||||||
'meta+6': wrapFormatAction(toggleSelectionAsBlockquote),
|
'meta+6': wrapFormatAction(toggleSelectionAsBlockquote),
|
||||||
|
@ -13,7 +13,6 @@ import codeIcon from "@icons/editor/code.svg";
|
|||||||
import formatClearIcon from "@icons/editor/format-clear.svg";
|
import formatClearIcon from "@icons/editor/format-clear.svg";
|
||||||
import {$selectionContainsTextFormat} from "../../../utils/selection";
|
import {$selectionContainsTextFormat} from "../../../utils/selection";
|
||||||
import {$patchStyleText} from "@lexical/selection";
|
import {$patchStyleText} from "@lexical/selection";
|
||||||
import {context} from "esbuild";
|
|
||||||
|
|
||||||
function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition {
|
function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition {
|
||||||
return {
|
return {
|
||||||
@ -32,7 +31,7 @@ export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', bo
|
|||||||
export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon);
|
export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon);
|
||||||
export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon);
|
export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon);
|
||||||
export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon};
|
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 {
|
function colorAction(context: EditorUiContext, property: string, color: string): void {
|
||||||
context.editor.update(() => {
|
context.editor.update(() => {
|
||||||
@ -44,7 +43,7 @@ function colorAction(context: EditorUiContext, property: string, color: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const textColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color);
|
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 strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon);
|
||||||
export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon);
|
export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon);
|
||||||
|
@ -75,7 +75,7 @@ export function $showCellPropertiesForm(cell: TableCellNode, context: EditorUiCo
|
|||||||
border_width: styles.get('border-width') || '',
|
border_width: styles.get('border-width') || '',
|
||||||
border_style: styles.get('border-style') || '',
|
border_style: styles.get('border-style') || '',
|
||||||
border_color: styles.get('border-color') || '',
|
border_color: styles.get('border-color') || '',
|
||||||
background_color: styles.get('background-color') || '',
|
background_color: cell.getBackgroundColor() || styles.get('background-color') || '',
|
||||||
});
|
});
|
||||||
return modalForm;
|
return modalForm;
|
||||||
}
|
}
|
||||||
@ -91,6 +91,7 @@ export const cellProperties: EditorFormDefinition = {
|
|||||||
$setTableCellColumnWidth(cell, width);
|
$setTableCellColumnWidth(cell, width);
|
||||||
cell.updateTag(formData.get('type')?.toString() || '');
|
cell.updateTag(formData.get('type')?.toString() || '');
|
||||||
cell.setAlignment((formData.get('h_align')?.toString() || '') as CommonBlockAlignment);
|
cell.setAlignment((formData.get('h_align')?.toString() || '') as CommonBlockAlignment);
|
||||||
|
cell.setBackgroundColor(formData.get('background_color')?.toString() || '');
|
||||||
|
|
||||||
const styles = cell.getStyles();
|
const styles = cell.getStyles();
|
||||||
styles.set('height', formatSizeValue(formData.get('height')?.toString() || ''));
|
styles.set('height', formatSizeValue(formData.get('height')?.toString() || ''));
|
||||||
@ -98,7 +99,6 @@ export const cellProperties: EditorFormDefinition = {
|
|||||||
styles.set('border-width', formatSizeValue(formData.get('border_width')?.toString() || ''));
|
styles.set('border-width', formatSizeValue(formData.get('border_width')?.toString() || ''));
|
||||||
styles.set('border-style', formData.get('border_style')?.toString() || '');
|
styles.set('border-style', formData.get('border_style')?.toString() || '');
|
||||||
styles.set('border-color', formData.get('border_color')?.toString() || '');
|
styles.set('border-color', formData.get('border_color')?.toString() || '');
|
||||||
styles.set('background-color', formData.get('background_color')?.toString() || '');
|
|
||||||
|
|
||||||
cell.setStyles(styles);
|
cell.setStyles(styles);
|
||||||
}
|
}
|
||||||
|
@ -282,6 +282,7 @@ export function $clearTableFormatting(table: TableNode): void {
|
|||||||
const cells = row.getChildren().filter(c => $isTableCellNode(c));
|
const cells = row.getChildren().filter(c => $isTableCellNode(c));
|
||||||
for (const cell of cells) {
|
for (const cell of cells) {
|
||||||
cell.setStyles(new Map);
|
cell.setStyles(new Map);
|
||||||
|
cell.setBackgroundColor(null);
|
||||||
cell.clearWidth();
|
cell.clearWidth();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -681,6 +681,14 @@ textarea.editor-form-field-input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 styles
|
||||||
.editor-theme-bold {
|
.editor-theme-bold {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
Reference in New Issue
Block a user