1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-07-28 17:02:04 +03:00

Lexical: Updated URL handling, added mouse handling

- Removed URL protocol allow-list to allow any as per old editor.
- Added mouse handling, so that clicks below many last hard-to-escape
  block types will add an empty new paragraph for easy escaping &
  editing.
This commit is contained in:
Dan Brown
2025-07-25 13:58:48 +01:00
parent 865e5aecc9
commit c54101c603
5 changed files with 133 additions and 22 deletions

View File

@ -19,6 +19,7 @@ import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "
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: {
@ -51,6 +52,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(context),
registerKeyboardHandling(context),
registerMouseHandling(context),
registerTableResizer(editor, context.scrollDOM),
registerTableSelectionHandler(editor),
registerTaskListHandler(editor, context.editorDOM),

View File

@ -848,4 +848,20 @@ export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, 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();
}

View File

@ -48,14 +48,6 @@ export type SerializedLinkNode = Spread<
type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
const SUPPORTED_URL_PROTOCOLS = new Set([
'http:',
'https:',
'mailto:',
'sms:',
'tel:',
]);
/** @noInheritDoc */
export class LinkNode extends ElementNode {
/** @internal */
@ -90,7 +82,7 @@ export class LinkNode extends ElementNode {
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;
}
@ -166,19 +158,6 @@ export class LinkNode extends ElementNode {
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(),

View File

@ -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);
});
});

View 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();
};
}