mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-22 15:22:36 +00:00
2add15bd72
Also removed duplicated dir functionality that remained in core.
1056 lines
28 KiB
TypeScript
1056 lines
28 KiB
TypeScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
*/
|
|
|
|
import type {
|
|
CommandPayloadType,
|
|
DOMConversionMap,
|
|
DOMConversionOutput,
|
|
DOMExportOutput,
|
|
EditorConfig,
|
|
ElementFormatType,
|
|
LexicalCommand,
|
|
LexicalEditor,
|
|
LexicalNode,
|
|
NodeKey,
|
|
ParagraphNode,
|
|
PasteCommandType,
|
|
RangeSelection,
|
|
SerializedElementNode,
|
|
Spread,
|
|
TextFormatType,
|
|
} from 'lexical';
|
|
|
|
import {
|
|
$insertDataTransferForRichText,
|
|
copyToClipboard,
|
|
} from '@lexical/clipboard';
|
|
import {
|
|
$moveCharacter,
|
|
$shouldOverrideDefaultCharacterSelection,
|
|
} from '@lexical/selection';
|
|
import {
|
|
$findMatchingParent,
|
|
$getNearestBlockElementAncestorOrThrow,
|
|
addClassNamesToElement,
|
|
isHTMLElement,
|
|
mergeRegister,
|
|
objectKlassEquals,
|
|
} from '@lexical/utils';
|
|
import {
|
|
$applyNodeReplacement,
|
|
$createParagraphNode,
|
|
$createRangeSelection,
|
|
$createTabNode,
|
|
$getAdjacentNode,
|
|
$getNearestNodeFromDOMNode,
|
|
$getRoot,
|
|
$getSelection,
|
|
$insertNodes,
|
|
$isDecoratorNode,
|
|
$isElementNode,
|
|
$isNodeSelection,
|
|
$isRangeSelection,
|
|
$isRootNode,
|
|
$isTextNode,
|
|
$normalizeSelection__EXPERIMENTAL,
|
|
$selectAll,
|
|
$setSelection,
|
|
CLICK_COMMAND,
|
|
COMMAND_PRIORITY_EDITOR,
|
|
CONTROLLED_TEXT_INSERTION_COMMAND,
|
|
COPY_COMMAND,
|
|
createCommand,
|
|
CUT_COMMAND,
|
|
DELETE_CHARACTER_COMMAND,
|
|
DELETE_LINE_COMMAND,
|
|
DELETE_WORD_COMMAND,
|
|
DRAGOVER_COMMAND,
|
|
DRAGSTART_COMMAND,
|
|
DROP_COMMAND,
|
|
ElementNode,
|
|
FORMAT_ELEMENT_COMMAND,
|
|
FORMAT_TEXT_COMMAND,
|
|
INDENT_CONTENT_COMMAND,
|
|
INSERT_LINE_BREAK_COMMAND,
|
|
INSERT_PARAGRAPH_COMMAND,
|
|
INSERT_TAB_COMMAND,
|
|
isSelectionCapturedInDecoratorInput,
|
|
KEY_ARROW_DOWN_COMMAND,
|
|
KEY_ARROW_LEFT_COMMAND,
|
|
KEY_ARROW_RIGHT_COMMAND,
|
|
KEY_ARROW_UP_COMMAND,
|
|
KEY_BACKSPACE_COMMAND,
|
|
KEY_DELETE_COMMAND,
|
|
KEY_ENTER_COMMAND,
|
|
KEY_ESCAPE_COMMAND,
|
|
OUTDENT_CONTENT_COMMAND,
|
|
PASTE_COMMAND,
|
|
REMOVE_TEXT_COMMAND,
|
|
SELECT_ALL_COMMAND,
|
|
} from 'lexical';
|
|
import caretFromPoint from 'lexical/shared/caretFromPoint';
|
|
import {
|
|
CAN_USE_BEFORE_INPUT,
|
|
IS_APPLE_WEBKIT,
|
|
IS_IOS,
|
|
IS_SAFARI,
|
|
} from 'lexical/shared/environment';
|
|
|
|
export type SerializedHeadingNode = Spread<
|
|
{
|
|
tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
},
|
|
SerializedElementNode
|
|
>;
|
|
|
|
export const DRAG_DROP_PASTE: LexicalCommand<Array<File>> = createCommand(
|
|
'DRAG_DROP_PASTE_FILE',
|
|
);
|
|
|
|
export type SerializedQuoteNode = SerializedElementNode;
|
|
|
|
/** @noInheritDoc */
|
|
export class QuoteNode extends ElementNode {
|
|
static getType(): string {
|
|
return 'quote';
|
|
}
|
|
|
|
static clone(node: QuoteNode): QuoteNode {
|
|
return new QuoteNode(node.__key);
|
|
}
|
|
|
|
constructor(key?: NodeKey) {
|
|
super(key);
|
|
}
|
|
|
|
// View
|
|
|
|
createDOM(config: EditorConfig): HTMLElement {
|
|
const element = document.createElement('blockquote');
|
|
addClassNamesToElement(element, config.theme.quote);
|
|
return element;
|
|
}
|
|
updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean {
|
|
return false;
|
|
}
|
|
|
|
static importDOM(): DOMConversionMap | null {
|
|
return {
|
|
blockquote: (node: Node) => ({
|
|
conversion: $convertBlockquoteElement,
|
|
priority: 0,
|
|
}),
|
|
};
|
|
}
|
|
|
|
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
|
const {element} = super.exportDOM(editor);
|
|
|
|
if (element && isHTMLElement(element)) {
|
|
if (this.isEmpty()) {
|
|
element.append(document.createElement('br'));
|
|
}
|
|
|
|
const formatType = this.getFormatType();
|
|
element.style.textAlign = formatType;
|
|
}
|
|
|
|
return {
|
|
element,
|
|
};
|
|
}
|
|
|
|
static importJSON(serializedNode: SerializedQuoteNode): QuoteNode {
|
|
const node = $createQuoteNode();
|
|
node.setFormat(serializedNode.format);
|
|
node.setIndent(serializedNode.indent);
|
|
return node;
|
|
}
|
|
|
|
exportJSON(): SerializedElementNode {
|
|
return {
|
|
...super.exportJSON(),
|
|
type: 'quote',
|
|
};
|
|
}
|
|
|
|
// Mutation
|
|
|
|
insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode {
|
|
const newBlock = $createParagraphNode();
|
|
const direction = this.getDirection();
|
|
newBlock.setDirection(direction);
|
|
this.insertAfter(newBlock, restoreSelection);
|
|
return newBlock;
|
|
}
|
|
|
|
collapseAtStart(): true {
|
|
const paragraph = $createParagraphNode();
|
|
const children = this.getChildren();
|
|
children.forEach((child) => paragraph.append(child));
|
|
this.replace(paragraph);
|
|
return true;
|
|
}
|
|
|
|
canMergeWhenEmpty(): true {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export function $createQuoteNode(): QuoteNode {
|
|
return $applyNodeReplacement(new QuoteNode());
|
|
}
|
|
|
|
export function $isQuoteNode(
|
|
node: LexicalNode | null | undefined,
|
|
): node is QuoteNode {
|
|
return node instanceof QuoteNode;
|
|
}
|
|
|
|
export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
|
|
/** @noInheritDoc */
|
|
export class HeadingNode extends ElementNode {
|
|
/** @internal */
|
|
__tag: HeadingTagType;
|
|
|
|
static getType(): string {
|
|
return 'heading';
|
|
}
|
|
|
|
static clone(node: HeadingNode): HeadingNode {
|
|
return new HeadingNode(node.__tag, node.__key);
|
|
}
|
|
|
|
constructor(tag: HeadingTagType, key?: NodeKey) {
|
|
super(key);
|
|
this.__tag = tag;
|
|
}
|
|
|
|
getTag(): HeadingTagType {
|
|
return this.__tag;
|
|
}
|
|
|
|
// View
|
|
|
|
createDOM(config: EditorConfig): HTMLElement {
|
|
const tag = this.__tag;
|
|
const element = document.createElement(tag);
|
|
const theme = config.theme;
|
|
const classNames = theme.heading;
|
|
if (classNames !== undefined) {
|
|
const className = classNames[tag];
|
|
addClassNamesToElement(element, className);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean {
|
|
return false;
|
|
}
|
|
|
|
static importDOM(): DOMConversionMap | null {
|
|
return {
|
|
h1: (node: Node) => ({
|
|
conversion: $convertHeadingElement,
|
|
priority: 0,
|
|
}),
|
|
h2: (node: Node) => ({
|
|
conversion: $convertHeadingElement,
|
|
priority: 0,
|
|
}),
|
|
h3: (node: Node) => ({
|
|
conversion: $convertHeadingElement,
|
|
priority: 0,
|
|
}),
|
|
h4: (node: Node) => ({
|
|
conversion: $convertHeadingElement,
|
|
priority: 0,
|
|
}),
|
|
h5: (node: Node) => ({
|
|
conversion: $convertHeadingElement,
|
|
priority: 0,
|
|
}),
|
|
h6: (node: Node) => ({
|
|
conversion: $convertHeadingElement,
|
|
priority: 0,
|
|
}),
|
|
p: (node: Node) => {
|
|
// domNode is a <p> since we matched it by nodeName
|
|
const paragraph = node as HTMLParagraphElement;
|
|
const firstChild = paragraph.firstChild;
|
|
if (firstChild !== null && isGoogleDocsTitle(firstChild)) {
|
|
return {
|
|
conversion: () => ({node: null}),
|
|
priority: 3,
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
span: (node: Node) => {
|
|
if (isGoogleDocsTitle(node)) {
|
|
return {
|
|
conversion: (domNode: Node) => {
|
|
return {
|
|
node: $createHeadingNode('h1'),
|
|
};
|
|
},
|
|
priority: 3,
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
}
|
|
|
|
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
|
const {element} = super.exportDOM(editor);
|
|
|
|
if (element && isHTMLElement(element)) {
|
|
if (this.isEmpty()) {
|
|
element.append(document.createElement('br'));
|
|
}
|
|
|
|
const formatType = this.getFormatType();
|
|
element.style.textAlign = formatType;
|
|
}
|
|
|
|
return {
|
|
element,
|
|
};
|
|
}
|
|
|
|
static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
|
|
const node = $createHeadingNode(serializedNode.tag);
|
|
node.setFormat(serializedNode.format);
|
|
node.setIndent(serializedNode.indent);
|
|
return node;
|
|
}
|
|
|
|
exportJSON(): SerializedHeadingNode {
|
|
return {
|
|
...super.exportJSON(),
|
|
tag: this.getTag(),
|
|
type: 'heading',
|
|
version: 1,
|
|
};
|
|
}
|
|
|
|
// Mutation
|
|
insertNewAfter(
|
|
selection?: RangeSelection,
|
|
restoreSelection = true,
|
|
): ParagraphNode | HeadingNode {
|
|
const anchorOffet = selection ? selection.anchor.offset : 0;
|
|
const lastDesc = this.getLastDescendant();
|
|
const isAtEnd =
|
|
!lastDesc ||
|
|
(selection &&
|
|
selection.anchor.key === lastDesc.getKey() &&
|
|
anchorOffet === lastDesc.getTextContentSize());
|
|
const newElement =
|
|
isAtEnd || !selection
|
|
? $createParagraphNode()
|
|
: $createHeadingNode(this.getTag());
|
|
const direction = this.getDirection();
|
|
newElement.setDirection(direction);
|
|
this.insertAfter(newElement, restoreSelection);
|
|
if (anchorOffet === 0 && !this.isEmpty() && selection) {
|
|
const paragraph = $createParagraphNode();
|
|
paragraph.select();
|
|
this.replace(paragraph, true);
|
|
}
|
|
return newElement;
|
|
}
|
|
|
|
collapseAtStart(): true {
|
|
const newElement = !this.isEmpty()
|
|
? $createHeadingNode(this.getTag())
|
|
: $createParagraphNode();
|
|
const children = this.getChildren();
|
|
children.forEach((child) => newElement.append(child));
|
|
this.replace(newElement);
|
|
return true;
|
|
}
|
|
|
|
extractWithChild(): boolean {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function isGoogleDocsTitle(domNode: Node): boolean {
|
|
if (domNode.nodeName.toLowerCase() === 'span') {
|
|
return (domNode as HTMLSpanElement).style.fontSize === '26pt';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function $convertHeadingElement(element: HTMLElement): DOMConversionOutput {
|
|
const nodeName = element.nodeName.toLowerCase();
|
|
let node = null;
|
|
if (
|
|
nodeName === 'h1' ||
|
|
nodeName === 'h2' ||
|
|
nodeName === 'h3' ||
|
|
nodeName === 'h4' ||
|
|
nodeName === 'h5' ||
|
|
nodeName === 'h6'
|
|
) {
|
|
node = $createHeadingNode(nodeName);
|
|
if (element.style !== null) {
|
|
node.setFormat(element.style.textAlign as ElementFormatType);
|
|
}
|
|
}
|
|
return {node};
|
|
}
|
|
|
|
function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput {
|
|
const node = $createQuoteNode();
|
|
if (element.style !== null) {
|
|
node.setFormat(element.style.textAlign as ElementFormatType);
|
|
}
|
|
return {node};
|
|
}
|
|
|
|
export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode {
|
|
return $applyNodeReplacement(new HeadingNode(headingTag));
|
|
}
|
|
|
|
export function $isHeadingNode(
|
|
node: LexicalNode | null | undefined,
|
|
): node is HeadingNode {
|
|
return node instanceof HeadingNode;
|
|
}
|
|
|
|
function onPasteForRichText(
|
|
event: CommandPayloadType<typeof PASTE_COMMAND>,
|
|
editor: LexicalEditor,
|
|
): void {
|
|
event.preventDefault();
|
|
editor.update(
|
|
() => {
|
|
const selection = $getSelection();
|
|
const clipboardData =
|
|
objectKlassEquals(event, InputEvent) ||
|
|
objectKlassEquals(event, KeyboardEvent)
|
|
? null
|
|
: (event as ClipboardEvent).clipboardData;
|
|
if (clipboardData != null && selection !== null) {
|
|
$insertDataTransferForRichText(clipboardData, selection, editor);
|
|
}
|
|
},
|
|
{
|
|
tag: 'paste',
|
|
},
|
|
);
|
|
}
|
|
|
|
async function onCutForRichText(
|
|
event: CommandPayloadType<typeof CUT_COMMAND>,
|
|
editor: LexicalEditor,
|
|
): Promise<void> {
|
|
await copyToClipboard(
|
|
editor,
|
|
objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null,
|
|
);
|
|
editor.update(() => {
|
|
const selection = $getSelection();
|
|
if ($isRangeSelection(selection)) {
|
|
selection.removeText();
|
|
} else if ($isNodeSelection(selection)) {
|
|
selection.getNodes().forEach((node) => node.remove());
|
|
}
|
|
});
|
|
}
|
|
|
|
// Clipboard may contain files that we aren't allowed to read. While the event is arguably useless,
|
|
// in certain occasions, we want to know whether it was a file transfer, as opposed to text. We
|
|
// control this with the first boolean flag.
|
|
export function eventFiles(
|
|
event: DragEvent | PasteCommandType,
|
|
): [boolean, Array<File>, boolean] {
|
|
let dataTransfer: null | DataTransfer = null;
|
|
if (objectKlassEquals(event, DragEvent)) {
|
|
dataTransfer = (event as DragEvent).dataTransfer;
|
|
} else if (objectKlassEquals(event, ClipboardEvent)) {
|
|
dataTransfer = (event as ClipboardEvent).clipboardData;
|
|
}
|
|
|
|
if (dataTransfer === null) {
|
|
return [false, [], false];
|
|
}
|
|
|
|
const types = dataTransfer.types;
|
|
const hasFiles = types.includes('Files');
|
|
const hasContent =
|
|
types.includes('text/html') || types.includes('text/plain');
|
|
return [hasFiles, Array.from(dataTransfer.files), hasContent];
|
|
}
|
|
|
|
function $handleIndentAndOutdent(
|
|
indentOrOutdent: (block: ElementNode) => void,
|
|
): boolean {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
const alreadyHandled = new Set();
|
|
const nodes = selection.getNodes();
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const node = nodes[i];
|
|
const key = node.getKey();
|
|
if (alreadyHandled.has(key)) {
|
|
continue;
|
|
}
|
|
const parentBlock = $findMatchingParent(
|
|
node,
|
|
(parentNode): parentNode is ElementNode =>
|
|
$isElementNode(parentNode) && !parentNode.isInline(),
|
|
);
|
|
if (parentBlock === null) {
|
|
continue;
|
|
}
|
|
const parentKey = parentBlock.getKey();
|
|
if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) {
|
|
alreadyHandled.add(parentKey);
|
|
indentOrOutdent(parentBlock);
|
|
}
|
|
}
|
|
return alreadyHandled.size > 0;
|
|
}
|
|
|
|
function $isTargetWithinDecorator(target: HTMLElement): boolean {
|
|
const node = $getNearestNodeFromDOMNode(target);
|
|
return $isDecoratorNode(node);
|
|
}
|
|
|
|
function $isSelectionAtEndOfRoot(selection: RangeSelection) {
|
|
const focus = selection.focus;
|
|
return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
|
|
}
|
|
|
|
export function registerRichText(editor: LexicalEditor): () => void {
|
|
const removeListener = mergeRegister(
|
|
editor.registerCommand(
|
|
CLICK_COMMAND,
|
|
(payload) => {
|
|
const selection = $getSelection();
|
|
if ($isNodeSelection(selection)) {
|
|
selection.clear();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
0,
|
|
),
|
|
editor.registerCommand<boolean>(
|
|
DELETE_CHARACTER_COMMAND,
|
|
(isBackward) => {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
selection.deleteCharacter(isBackward);
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<boolean>(
|
|
DELETE_WORD_COMMAND,
|
|
(isBackward) => {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
selection.deleteWord(isBackward);
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<boolean>(
|
|
DELETE_LINE_COMMAND,
|
|
(isBackward) => {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
selection.deleteLine(isBackward);
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
CONTROLLED_TEXT_INSERTION_COMMAND,
|
|
(eventOrText) => {
|
|
const selection = $getSelection();
|
|
|
|
if (typeof eventOrText === 'string') {
|
|
if (selection !== null) {
|
|
selection.insertText(eventOrText);
|
|
}
|
|
} else {
|
|
if (selection === null) {
|
|
return false;
|
|
}
|
|
|
|
const dataTransfer = eventOrText.dataTransfer;
|
|
if (dataTransfer != null) {
|
|
$insertDataTransferForRichText(dataTransfer, selection, editor);
|
|
} else if ($isRangeSelection(selection)) {
|
|
const data = eventOrText.data;
|
|
if (data) {
|
|
selection.insertText(data);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
REMOVE_TEXT_COMMAND,
|
|
() => {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
selection.removeText();
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<TextFormatType>(
|
|
FORMAT_TEXT_COMMAND,
|
|
(format) => {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
selection.formatText(format);
|
|
return true;
|
|
},
|
|
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(),
|
|
);
|
|
if (element !== null) {
|
|
element.setFormat(format);
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<boolean>(
|
|
INSERT_LINE_BREAK_COMMAND,
|
|
(selectStart) => {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
selection.insertLineBreak(selectStart);
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
INSERT_PARAGRAPH_COMMAND,
|
|
() => {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
selection.insertParagraph();
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
INSERT_TAB_COMMAND,
|
|
() => {
|
|
$insertNodes([$createTabNode()]);
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
INDENT_CONTENT_COMMAND,
|
|
() => {
|
|
return $handleIndentAndOutdent((block) => {
|
|
const indent = block.getIndent();
|
|
block.setIndent(indent + 1);
|
|
});
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
OUTDENT_CONTENT_COMMAND,
|
|
() => {
|
|
return $handleIndentAndOutdent((block) => {
|
|
const indent = block.getIndent();
|
|
if (indent > 0) {
|
|
block.setIndent(indent - 1);
|
|
}
|
|
});
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_ARROW_UP_COMMAND,
|
|
(event) => {
|
|
const selection = $getSelection();
|
|
if (
|
|
$isNodeSelection(selection) &&
|
|
!$isTargetWithinDecorator(event.target as HTMLElement)
|
|
) {
|
|
// If selection is on a node, let's try and move selection
|
|
// back to being a range selection.
|
|
const nodes = selection.getNodes();
|
|
if (nodes.length > 0) {
|
|
nodes[0].selectPrevious();
|
|
return true;
|
|
}
|
|
} else if ($isRangeSelection(selection)) {
|
|
const possibleNode = $getAdjacentNode(selection.focus, true);
|
|
if (
|
|
!event.shiftKey &&
|
|
$isDecoratorNode(possibleNode) &&
|
|
!possibleNode.isIsolated() &&
|
|
!possibleNode.isInline()
|
|
) {
|
|
possibleNode.selectPrevious();
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_ARROW_DOWN_COMMAND,
|
|
(event) => {
|
|
const selection = $getSelection();
|
|
if ($isNodeSelection(selection)) {
|
|
// If selection is on a node, let's try and move selection
|
|
// back to being a range selection.
|
|
const nodes = selection.getNodes();
|
|
if (nodes.length > 0) {
|
|
nodes[0].selectNext(0, 0);
|
|
return true;
|
|
}
|
|
} else if ($isRangeSelection(selection)) {
|
|
if ($isSelectionAtEndOfRoot(selection)) {
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
const possibleNode = $getAdjacentNode(selection.focus, false);
|
|
if (
|
|
!event.shiftKey &&
|
|
$isDecoratorNode(possibleNode) &&
|
|
!possibleNode.isIsolated() &&
|
|
!possibleNode.isInline()
|
|
) {
|
|
possibleNode.selectNext();
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_ARROW_LEFT_COMMAND,
|
|
(event) => {
|
|
const selection = $getSelection();
|
|
if ($isNodeSelection(selection)) {
|
|
// If selection is on a node, let's try and move selection
|
|
// back to being a range selection.
|
|
const nodes = selection.getNodes();
|
|
if (nodes.length > 0) {
|
|
event.preventDefault();
|
|
nodes[0].selectPrevious();
|
|
return true;
|
|
}
|
|
}
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
|
|
const isHoldingShift = event.shiftKey;
|
|
event.preventDefault();
|
|
$moveCharacter(selection, isHoldingShift, true);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_ARROW_RIGHT_COMMAND,
|
|
(event) => {
|
|
const selection = $getSelection();
|
|
if (
|
|
$isNodeSelection(selection) &&
|
|
!$isTargetWithinDecorator(event.target as HTMLElement)
|
|
) {
|
|
// If selection is on a node, let's try and move selection
|
|
// back to being a range selection.
|
|
const nodes = selection.getNodes();
|
|
if (nodes.length > 0) {
|
|
event.preventDefault();
|
|
nodes[0].selectNext(0, 0);
|
|
return true;
|
|
}
|
|
}
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
const isHoldingShift = event.shiftKey;
|
|
if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
|
|
event.preventDefault();
|
|
$moveCharacter(selection, isHoldingShift, false);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_BACKSPACE_COMMAND,
|
|
(event) => {
|
|
if ($isTargetWithinDecorator(event.target as HTMLElement)) {
|
|
return false;
|
|
}
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
event.preventDefault();
|
|
const {anchor} = selection;
|
|
const anchorNode = anchor.getNode();
|
|
|
|
if (
|
|
selection.isCollapsed() &&
|
|
anchor.offset === 0 &&
|
|
!$isRootNode(anchorNode)
|
|
) {
|
|
const element = $getNearestBlockElementAncestorOrThrow(anchorNode);
|
|
if (element.getIndent() > 0) {
|
|
return editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
|
|
}
|
|
}
|
|
return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<KeyboardEvent>(
|
|
KEY_DELETE_COMMAND,
|
|
(event) => {
|
|
if ($isTargetWithinDecorator(event.target as HTMLElement)) {
|
|
return false;
|
|
}
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
event.preventDefault();
|
|
return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<KeyboardEvent | null>(
|
|
KEY_ENTER_COMMAND,
|
|
(event) => {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
if (event !== null) {
|
|
// If we have beforeinput, then we can avoid blocking
|
|
// the default behavior. This ensures that the iOS can
|
|
// intercept that we're actually inserting a paragraph,
|
|
// and autocomplete, autocapitalize etc work as intended.
|
|
// This can also cause a strange performance issue in
|
|
// Safari, where there is a noticeable pause due to
|
|
// preventing the key down of enter.
|
|
if (
|
|
(IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
|
|
CAN_USE_BEFORE_INPUT
|
|
) {
|
|
return false;
|
|
}
|
|
event.preventDefault();
|
|
if (event.shiftKey) {
|
|
return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
|
|
}
|
|
}
|
|
return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
KEY_ESCAPE_COMMAND,
|
|
() => {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
editor.blur();
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<DragEvent>(
|
|
DROP_COMMAND,
|
|
(event) => {
|
|
const [, files] = eventFiles(event);
|
|
if (files.length > 0) {
|
|
const x = event.clientX;
|
|
const y = event.clientY;
|
|
const eventRange = caretFromPoint(x, y);
|
|
if (eventRange !== null) {
|
|
const {offset: domOffset, node: domNode} = eventRange;
|
|
const node = $getNearestNodeFromDOMNode(domNode);
|
|
if (node !== null) {
|
|
const selection = $createRangeSelection();
|
|
if ($isTextNode(node)) {
|
|
selection.anchor.set(node.getKey(), domOffset, 'text');
|
|
selection.focus.set(node.getKey(), domOffset, 'text');
|
|
} else {
|
|
const parentKey = node.getParentOrThrow().getKey();
|
|
const offset = node.getIndexWithinParent() + 1;
|
|
selection.anchor.set(parentKey, offset, 'element');
|
|
selection.focus.set(parentKey, offset, 'element');
|
|
}
|
|
const normalizedSelection =
|
|
$normalizeSelection__EXPERIMENTAL(selection);
|
|
$setSelection(normalizedSelection);
|
|
}
|
|
editor.dispatchCommand(DRAG_DROP_PASTE, files);
|
|
}
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
|
|
const selection = $getSelection();
|
|
if ($isRangeSelection(selection)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<DragEvent>(
|
|
DRAGSTART_COMMAND,
|
|
(event) => {
|
|
const [isFileTransfer] = eventFiles(event);
|
|
const selection = $getSelection();
|
|
if (isFileTransfer && !$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<DragEvent>(
|
|
DRAGOVER_COMMAND,
|
|
(event) => {
|
|
const [isFileTransfer] = eventFiles(event);
|
|
const selection = $getSelection();
|
|
if (isFileTransfer && !$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
const x = event.clientX;
|
|
const y = event.clientY;
|
|
const eventRange = caretFromPoint(x, y);
|
|
if (eventRange !== null) {
|
|
const node = $getNearestNodeFromDOMNode(eventRange.node);
|
|
if ($isDecoratorNode(node)) {
|
|
// Show browser caret as the user is dragging the media across the screen. Won't work
|
|
// for DecoratorNode nor it's relevant.
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
SELECT_ALL_COMMAND,
|
|
() => {
|
|
$selectAll();
|
|
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
COPY_COMMAND,
|
|
(event) => {
|
|
copyToClipboard(
|
|
editor,
|
|
objectKlassEquals(event, ClipboardEvent)
|
|
? (event as ClipboardEvent)
|
|
: null,
|
|
);
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
CUT_COMMAND,
|
|
(event) => {
|
|
onCutForRichText(event, editor);
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
PASTE_COMMAND,
|
|
(event) => {
|
|
const [, files, hasTextContent] = eventFiles(event);
|
|
if (files.length > 0 && !hasTextContent) {
|
|
editor.dispatchCommand(DRAG_DROP_PASTE, files);
|
|
return true;
|
|
}
|
|
|
|
// if inputs then paste within the input ignore creating a new node on paste event
|
|
if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
|
|
return false;
|
|
}
|
|
|
|
const selection = $getSelection();
|
|
if (selection !== null) {
|
|
onPasteForRichText(event, editor);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
);
|
|
return removeListener;
|
|
}
|