mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-30 04:23:11 +03:00
Lexical: Extracted & merged heading & quote nodes
This commit is contained in:
@ -8,42 +8,14 @@
|
||||
|
||||
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,
|
||||
@ -55,7 +27,6 @@ import {
|
||||
$isElementNode,
|
||||
$isNodeSelection,
|
||||
$isRangeSelection,
|
||||
$isRootNode,
|
||||
$isTextNode,
|
||||
$normalizeSelection__EXPERIMENTAL,
|
||||
$selectAll,
|
||||
@ -75,7 +46,6 @@ import {
|
||||
ElementNode,
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
INDENT_CONTENT_COMMAND,
|
||||
INSERT_LINE_BREAK_COMMAND,
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
INSERT_TAB_COMMAND,
|
||||
@ -88,327 +58,22 @@ import {
|
||||
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
|
||||
>;
|
||||
import {$insertDataTransferForRichText, copyToClipboard,} from '@lexical/clipboard';
|
||||
import {$moveCharacter, $shouldOverrideDefaultCharacterSelection,} from '@lexical/selection';
|
||||
import {$findMatchingParent, mergeRegister, objectKlassEquals,} from '@lexical/utils';
|
||||
import caretFromPoint from 'lexical/shared/caretFromPoint';
|
||||
import {CAN_USE_BEFORE_INPUT, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI,} from 'lexical/shared/environment';
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedQuoteNode): QuoteNode {
|
||||
const node = $createQuoteNode();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
|
||||
return $createHeadingNode(serializedNode.tag);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return {node};
|
||||
}
|
||||
|
||||
function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput {
|
||||
const node = $createQuoteNode();
|
||||
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>,
|
||||
|
Reference in New Issue
Block a user