mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-12-14 23:23:37 +08:00
543 lines
17 KiB
TypeScript
543 lines
17 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 {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
|
||
|
import {$addNodeStyle, $sliceSelectedTextNodeContent} from '@lexical/selection';
|
||
|
import {objectKlassEquals} from '@lexical/utils';
|
||
|
import {
|
||
|
$cloneWithProperties,
|
||
|
$createTabNode,
|
||
|
$getEditor,
|
||
|
$getRoot,
|
||
|
$getSelection,
|
||
|
$isElementNode,
|
||
|
$isRangeSelection,
|
||
|
$isTextNode,
|
||
|
$parseSerializedNode,
|
||
|
BaseSelection,
|
||
|
COMMAND_PRIORITY_CRITICAL,
|
||
|
COPY_COMMAND,
|
||
|
isSelectionWithinEditor,
|
||
|
LexicalEditor,
|
||
|
LexicalNode,
|
||
|
SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
|
||
|
SerializedElementNode,
|
||
|
SerializedTextNode,
|
||
|
} from 'lexical';
|
||
|
import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
|
||
|
import invariant from 'lexical/shared/invariant';
|
||
|
|
||
|
const getDOMSelection = (targetWindow: Window | null): Selection | null =>
|
||
|
CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
|
||
|
|
||
|
export interface LexicalClipboardData {
|
||
|
'text/html'?: string | undefined;
|
||
|
'application/x-lexical-editor'?: string | undefined;
|
||
|
'text/plain': string;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns the *currently selected* Lexical content as an HTML string, relying on the
|
||
|
* logic defined in the exportDOM methods on the LexicalNode classes. Note that
|
||
|
* this will not return the HTML content of the entire editor (unless all the content is included
|
||
|
* in the current selection).
|
||
|
*
|
||
|
* @param editor - LexicalEditor instance to get HTML content from
|
||
|
* @param selection - The selection to use (default is $getSelection())
|
||
|
* @returns a string of HTML content
|
||
|
*/
|
||
|
export function $getHtmlContent(
|
||
|
editor: LexicalEditor,
|
||
|
selection = $getSelection(),
|
||
|
): string {
|
||
|
if (selection == null) {
|
||
|
invariant(false, 'Expected valid LexicalSelection');
|
||
|
}
|
||
|
|
||
|
// If we haven't selected anything
|
||
|
if (
|
||
|
($isRangeSelection(selection) && selection.isCollapsed()) ||
|
||
|
selection.getNodes().length === 0
|
||
|
) {
|
||
|
return '';
|
||
|
}
|
||
|
|
||
|
return $generateHtmlFromNodes(editor, selection);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns the *currently selected* Lexical content as a JSON string, relying on the
|
||
|
* logic defined in the exportJSON methods on the LexicalNode classes. Note that
|
||
|
* this will not return the JSON content of the entire editor (unless all the content is included
|
||
|
* in the current selection).
|
||
|
*
|
||
|
* @param editor - LexicalEditor instance to get the JSON content from
|
||
|
* @param selection - The selection to use (default is $getSelection())
|
||
|
* @returns
|
||
|
*/
|
||
|
export function $getLexicalContent(
|
||
|
editor: LexicalEditor,
|
||
|
selection = $getSelection(),
|
||
|
): null | string {
|
||
|
if (selection == null) {
|
||
|
invariant(false, 'Expected valid LexicalSelection');
|
||
|
}
|
||
|
|
||
|
// If we haven't selected anything
|
||
|
if (
|
||
|
($isRangeSelection(selection) && selection.isCollapsed()) ||
|
||
|
selection.getNodes().length === 0
|
||
|
) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
return JSON.stringify($generateJSONFromSelectedNodes(editor, selection));
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Attempts to insert content of the mime-types text/plain or text/uri-list from
|
||
|
* the provided DataTransfer object into the editor at the provided selection.
|
||
|
* text/uri-list is only used if text/plain is not also provided.
|
||
|
*
|
||
|
* @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
|
||
|
* @param selection the selection to use as the insertion point for the content in the DataTransfer object
|
||
|
*/
|
||
|
export function $insertDataTransferForPlainText(
|
||
|
dataTransfer: DataTransfer,
|
||
|
selection: BaseSelection,
|
||
|
): void {
|
||
|
const text =
|
||
|
dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
|
||
|
|
||
|
if (text != null) {
|
||
|
selection.insertRawText(text);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Attempts to insert content of the mime-types application/x-lexical-editor, text/html,
|
||
|
* text/plain, or text/uri-list (in descending order of priority) from the provided DataTransfer
|
||
|
* object into the editor at the provided selection.
|
||
|
*
|
||
|
* @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
|
||
|
* @param selection the selection to use as the insertion point for the content in the DataTransfer object
|
||
|
* @param editor the LexicalEditor the content is being inserted into.
|
||
|
*/
|
||
|
export function $insertDataTransferForRichText(
|
||
|
dataTransfer: DataTransfer,
|
||
|
selection: BaseSelection,
|
||
|
editor: LexicalEditor,
|
||
|
): void {
|
||
|
const lexicalString = dataTransfer.getData('application/x-lexical-editor');
|
||
|
|
||
|
if (lexicalString) {
|
||
|
try {
|
||
|
const payload = JSON.parse(lexicalString);
|
||
|
if (
|
||
|
payload.namespace === editor._config.namespace &&
|
||
|
Array.isArray(payload.nodes)
|
||
|
) {
|
||
|
const nodes = $generateNodesFromSerializedNodes(payload.nodes);
|
||
|
return $insertGeneratedNodes(editor, nodes, selection);
|
||
|
}
|
||
|
} catch {
|
||
|
// Fail silently.
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const htmlString = dataTransfer.getData('text/html');
|
||
|
if (htmlString) {
|
||
|
try {
|
||
|
const parser = new DOMParser();
|
||
|
const dom = parser.parseFromString(htmlString, 'text/html');
|
||
|
const nodes = $generateNodesFromDOM(editor, dom);
|
||
|
return $insertGeneratedNodes(editor, nodes, selection);
|
||
|
} catch {
|
||
|
// Fail silently.
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Multi-line plain text in rich text mode pasted as separate paragraphs
|
||
|
// instead of single paragraph with linebreaks.
|
||
|
// Webkit-specific: Supports read 'text/uri-list' in clipboard.
|
||
|
const text =
|
||
|
dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
|
||
|
if (text != null) {
|
||
|
if ($isRangeSelection(selection)) {
|
||
|
const parts = text.split(/(\r?\n|\t)/);
|
||
|
if (parts[parts.length - 1] === '') {
|
||
|
parts.pop();
|
||
|
}
|
||
|
for (let i = 0; i < parts.length; i++) {
|
||
|
const currentSelection = $getSelection();
|
||
|
if ($isRangeSelection(currentSelection)) {
|
||
|
const part = parts[i];
|
||
|
if (part === '\n' || part === '\r\n') {
|
||
|
currentSelection.insertParagraph();
|
||
|
} else if (part === '\t') {
|
||
|
currentSelection.insertNodes([$createTabNode()]);
|
||
|
} else {
|
||
|
currentSelection.insertText(part);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
selection.insertRawText(text);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Inserts Lexical nodes into the editor using different strategies depending on
|
||
|
* some simple selection-based heuristics. If you're looking for a generic way to
|
||
|
* to insert nodes into the editor at a specific selection point, you probably want
|
||
|
* {@link lexical.$insertNodes}
|
||
|
*
|
||
|
* @param editor LexicalEditor instance to insert the nodes into.
|
||
|
* @param nodes The nodes to insert.
|
||
|
* @param selection The selection to insert the nodes into.
|
||
|
*/
|
||
|
export function $insertGeneratedNodes(
|
||
|
editor: LexicalEditor,
|
||
|
nodes: Array<LexicalNode>,
|
||
|
selection: BaseSelection,
|
||
|
): void {
|
||
|
if (
|
||
|
!editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, {
|
||
|
nodes,
|
||
|
selection,
|
||
|
})
|
||
|
) {
|
||
|
selection.insertNodes(nodes);
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
export interface BaseSerializedNode {
|
||
|
children?: Array<BaseSerializedNode>;
|
||
|
type: string;
|
||
|
version: number;
|
||
|
}
|
||
|
|
||
|
function exportNodeToJSON<T extends LexicalNode>(node: T): BaseSerializedNode {
|
||
|
const serializedNode = node.exportJSON();
|
||
|
const nodeClass = node.constructor;
|
||
|
|
||
|
if (serializedNode.type !== nodeClass.getType()) {
|
||
|
invariant(
|
||
|
false,
|
||
|
'LexicalNode: Node %s does not implement .exportJSON().',
|
||
|
nodeClass.name,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
if ($isElementNode(node)) {
|
||
|
const serializedChildren = (serializedNode as SerializedElementNode)
|
||
|
.children;
|
||
|
if (!Array.isArray(serializedChildren)) {
|
||
|
invariant(
|
||
|
false,
|
||
|
'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.',
|
||
|
nodeClass.name,
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return serializedNode;
|
||
|
}
|
||
|
|
||
|
function $appendNodesToJSON(
|
||
|
editor: LexicalEditor,
|
||
|
selection: BaseSelection | null,
|
||
|
currentNode: LexicalNode,
|
||
|
targetArray: Array<BaseSerializedNode> = [],
|
||
|
): boolean {
|
||
|
let shouldInclude =
|
||
|
selection !== null ? currentNode.isSelected(selection) : true;
|
||
|
const shouldExclude =
|
||
|
$isElementNode(currentNode) && currentNode.excludeFromCopy('html');
|
||
|
let target = currentNode;
|
||
|
|
||
|
if (selection !== null) {
|
||
|
let clone = $cloneWithProperties(currentNode);
|
||
|
clone =
|
||
|
$isTextNode(clone) && selection !== null
|
||
|
? $sliceSelectedTextNodeContent(selection, clone)
|
||
|
: clone;
|
||
|
target = clone;
|
||
|
}
|
||
|
const children = $isElementNode(target) ? target.getChildren() : [];
|
||
|
|
||
|
const serializedNode = exportNodeToJSON(target);
|
||
|
|
||
|
// TODO: TextNode calls getTextContent() (NOT node.__text) within its exportJSON method
|
||
|
// which uses getLatest() to get the text from the original node with the same key.
|
||
|
// This is a deeper issue with the word "clone" here, it's still a reference to the
|
||
|
// same node as far as the LexicalEditor is concerned since it shares a key.
|
||
|
// We need a way to create a clone of a Node in memory with its own key, but
|
||
|
// until then this hack will work for the selected text extract use case.
|
||
|
if ($isTextNode(target)) {
|
||
|
const text = target.__text;
|
||
|
// If an uncollapsed selection ends or starts at the end of a line of specialized,
|
||
|
// TextNodes, such as code tokens, we will get a 'blank' TextNode here, i.e., one
|
||
|
// with text of length 0. We don't want this, it makes a confusing mess. Reset!
|
||
|
if (text.length > 0) {
|
||
|
(serializedNode as SerializedTextNode).text = text;
|
||
|
} else {
|
||
|
shouldInclude = false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
for (let i = 0; i < children.length; i++) {
|
||
|
const childNode = children[i];
|
||
|
const shouldIncludeChild = $appendNodesToJSON(
|
||
|
editor,
|
||
|
selection,
|
||
|
childNode,
|
||
|
serializedNode.children,
|
||
|
);
|
||
|
|
||
|
if (
|
||
|
!shouldInclude &&
|
||
|
$isElementNode(currentNode) &&
|
||
|
shouldIncludeChild &&
|
||
|
currentNode.extractWithChild(childNode, selection, 'clone')
|
||
|
) {
|
||
|
shouldInclude = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (shouldInclude && !shouldExclude) {
|
||
|
targetArray.push(serializedNode);
|
||
|
} else if (Array.isArray(serializedNode.children)) {
|
||
|
for (let i = 0; i < serializedNode.children.length; i++) {
|
||
|
const serializedChildNode = serializedNode.children[i];
|
||
|
targetArray.push(serializedChildNode);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return shouldInclude;
|
||
|
}
|
||
|
|
||
|
// TODO why $ function with Editor instance?
|
||
|
/**
|
||
|
* Gets the Lexical JSON of the nodes inside the provided Selection.
|
||
|
*
|
||
|
* @param editor LexicalEditor to get the JSON content from.
|
||
|
* @param selection Selection to get the JSON content from.
|
||
|
* @returns an object with the editor namespace and a list of serializable nodes as JavaScript objects.
|
||
|
*/
|
||
|
export function $generateJSONFromSelectedNodes<
|
||
|
SerializedNode extends BaseSerializedNode,
|
||
|
>(
|
||
|
editor: LexicalEditor,
|
||
|
selection: BaseSelection | null,
|
||
|
): {
|
||
|
namespace: string;
|
||
|
nodes: Array<SerializedNode>;
|
||
|
} {
|
||
|
const nodes: Array<SerializedNode> = [];
|
||
|
const root = $getRoot();
|
||
|
const topLevelChildren = root.getChildren();
|
||
|
for (let i = 0; i < topLevelChildren.length; i++) {
|
||
|
const topLevelNode = topLevelChildren[i];
|
||
|
$appendNodesToJSON(editor, selection, topLevelNode, nodes);
|
||
|
}
|
||
|
return {
|
||
|
namespace: editor._config.namespace,
|
||
|
nodes,
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* This method takes an array of objects conforming to the BaseSeralizedNode interface and returns
|
||
|
* an Array containing instances of the corresponding LexicalNode classes registered on the editor.
|
||
|
* Normally, you'd get an Array of BaseSerialized nodes from {@link $generateJSONFromSelectedNodes}
|
||
|
*
|
||
|
* @param serializedNodes an Array of objects conforming to the BaseSerializedNode interface.
|
||
|
* @returns an Array of Lexical Node objects.
|
||
|
*/
|
||
|
export function $generateNodesFromSerializedNodes(
|
||
|
serializedNodes: Array<BaseSerializedNode>,
|
||
|
): Array<LexicalNode> {
|
||
|
const nodes = [];
|
||
|
for (let i = 0; i < serializedNodes.length; i++) {
|
||
|
const serializedNode = serializedNodes[i];
|
||
|
const node = $parseSerializedNode(serializedNode);
|
||
|
if ($isTextNode(node)) {
|
||
|
$addNodeStyle(node);
|
||
|
}
|
||
|
nodes.push(node);
|
||
|
}
|
||
|
return nodes;
|
||
|
}
|
||
|
|
||
|
const EVENT_LATENCY = 50;
|
||
|
let clipboardEventTimeout: null | number = null;
|
||
|
|
||
|
// TODO custom selection
|
||
|
// TODO potentially have a node customizable version for plain text
|
||
|
/**
|
||
|
* Copies the content of the current selection to the clipboard in
|
||
|
* text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
|
||
|
* formats.
|
||
|
*
|
||
|
* @param editor the LexicalEditor instance to copy content from
|
||
|
* @param event the native browser ClipboardEvent to add the content to.
|
||
|
* @returns
|
||
|
*/
|
||
|
export async function copyToClipboard(
|
||
|
editor: LexicalEditor,
|
||
|
event: null | ClipboardEvent,
|
||
|
data?: LexicalClipboardData,
|
||
|
): Promise<boolean> {
|
||
|
if (clipboardEventTimeout !== null) {
|
||
|
// Prevent weird race conditions that can happen when this function is run multiple times
|
||
|
// synchronously. In the future, we can do better, we can cancel/override the previously running job.
|
||
|
return false;
|
||
|
}
|
||
|
if (event !== null) {
|
||
|
return new Promise((resolve, reject) => {
|
||
|
editor.update(() => {
|
||
|
resolve($copyToClipboardEvent(editor, event, data));
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
|
||
|
const rootElement = editor.getRootElement();
|
||
|
const windowDocument =
|
||
|
editor._window == null ? window.document : editor._window.document;
|
||
|
const domSelection = getDOMSelection(editor._window);
|
||
|
if (rootElement === null || domSelection === null) {
|
||
|
return false;
|
||
|
}
|
||
|
const element = windowDocument.createElement('span');
|
||
|
element.style.cssText = 'position: fixed; top: -1000px;';
|
||
|
element.append(windowDocument.createTextNode('#'));
|
||
|
rootElement.append(element);
|
||
|
const range = new Range();
|
||
|
range.setStart(element, 0);
|
||
|
range.setEnd(element, 1);
|
||
|
domSelection.removeAllRanges();
|
||
|
domSelection.addRange(range);
|
||
|
return new Promise((resolve, reject) => {
|
||
|
const removeListener = editor.registerCommand(
|
||
|
COPY_COMMAND,
|
||
|
(secondEvent) => {
|
||
|
if (objectKlassEquals(secondEvent, ClipboardEvent)) {
|
||
|
removeListener();
|
||
|
if (clipboardEventTimeout !== null) {
|
||
|
window.clearTimeout(clipboardEventTimeout);
|
||
|
clipboardEventTimeout = null;
|
||
|
}
|
||
|
resolve(
|
||
|
$copyToClipboardEvent(editor, secondEvent as ClipboardEvent, data),
|
||
|
);
|
||
|
}
|
||
|
// Block the entire copy flow while we wait for the next ClipboardEvent
|
||
|
return true;
|
||
|
},
|
||
|
COMMAND_PRIORITY_CRITICAL,
|
||
|
);
|
||
|
// If the above hack execCommand hack works, this timeout code should never fire. Otherwise,
|
||
|
// the listener will be quickly freed so that the user can reuse it again
|
||
|
clipboardEventTimeout = window.setTimeout(() => {
|
||
|
removeListener();
|
||
|
clipboardEventTimeout = null;
|
||
|
resolve(false);
|
||
|
}, EVENT_LATENCY);
|
||
|
windowDocument.execCommand('copy');
|
||
|
element.remove();
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// TODO shouldn't pass editor (pass namespace directly)
|
||
|
function $copyToClipboardEvent(
|
||
|
editor: LexicalEditor,
|
||
|
event: ClipboardEvent,
|
||
|
data?: LexicalClipboardData,
|
||
|
): boolean {
|
||
|
if (data === undefined) {
|
||
|
const domSelection = getDOMSelection(editor._window);
|
||
|
if (!domSelection) {
|
||
|
return false;
|
||
|
}
|
||
|
const anchorDOM = domSelection.anchorNode;
|
||
|
const focusDOM = domSelection.focusNode;
|
||
|
if (
|
||
|
anchorDOM !== null &&
|
||
|
focusDOM !== null &&
|
||
|
!isSelectionWithinEditor(editor, anchorDOM, focusDOM)
|
||
|
) {
|
||
|
return false;
|
||
|
}
|
||
|
const selection = $getSelection();
|
||
|
if (selection === null) {
|
||
|
return false;
|
||
|
}
|
||
|
data = $getClipboardDataFromSelection(selection);
|
||
|
}
|
||
|
event.preventDefault();
|
||
|
const clipboardData = event.clipboardData;
|
||
|
if (clipboardData === null) {
|
||
|
return false;
|
||
|
}
|
||
|
setLexicalClipboardDataTransfer(clipboardData, data);
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
const clipboardDataFunctions = [
|
||
|
['text/html', $getHtmlContent],
|
||
|
['application/x-lexical-editor', $getLexicalContent],
|
||
|
] as const;
|
||
|
|
||
|
/**
|
||
|
* Serialize the content of the current selection to strings in
|
||
|
* text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
|
||
|
* formats (as available).
|
||
|
*
|
||
|
* @param selection the selection to serialize (defaults to $getSelection())
|
||
|
* @returns LexicalClipboardData
|
||
|
*/
|
||
|
export function $getClipboardDataFromSelection(
|
||
|
selection: BaseSelection | null = $getSelection(),
|
||
|
): LexicalClipboardData {
|
||
|
const clipboardData: LexicalClipboardData = {
|
||
|
'text/plain': selection ? selection.getTextContent() : '',
|
||
|
};
|
||
|
if (selection) {
|
||
|
const editor = $getEditor();
|
||
|
for (const [mimeType, $editorFn] of clipboardDataFunctions) {
|
||
|
const v = $editorFn(editor, selection);
|
||
|
if (v !== null) {
|
||
|
clipboardData[mimeType] = v;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return clipboardData;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Call setData on the given clipboardData for each MIME type present
|
||
|
* in the given data (from {@link $getClipboardDataFromSelection})
|
||
|
*
|
||
|
* @param clipboardData the event.clipboardData to populate from data
|
||
|
* @param data The lexical data
|
||
|
*/
|
||
|
export function setLexicalClipboardDataTransfer(
|
||
|
clipboardData: DataTransfer,
|
||
|
data: LexicalClipboardData,
|
||
|
) {
|
||
|
for (const k in data) {
|
||
|
const v = data[k as keyof LexicalClipboardData];
|
||
|
if (v !== undefined) {
|
||
|
clipboardData.setData(k, v);
|
||
|
}
|
||
|
}
|
||
|
}
|