@lexical/clipboard
Version:
This package provides the copy/paste functionality for Lexical.
537 lines (514 loc) • 17.5 kB
text/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 {BaseSelection, RangeSelection} from 'lexical';
import {$getPeerDependency, configExtension} from '@lexical/extension';
import {
$generateNodesFromDOM,
$generateNodesFromDOMViaExtension,
contextValue,
ImportSource,
ImportSourceDataTransfer,
} from '@lexical/html';
import {
$createTabNode,
$getEditor,
$getSelection,
$isRangeSelection,
defineExtension,
safeCast,
shallowMergeConfig,
tokenizeRawText,
} from 'lexical';
import {
$generateNodesFromSerializedNodes,
$insertGeneratedNodes,
LexicalClipboardData,
} from './clipboard';
/**
* A middleware function in a per-MIME-type clipboard-import stack. Mirrors
* the shape of {@link ExportMimeTypeFunction} on the export side.
*
* - `data` is the non-empty string returned by `DataTransfer.getData(mime)`
* for this MIME type.
* - `selection` is the current editor selection at the insertion point.
* - `$next` defers to the next-lower handler in the stack (i.e. the handler
* that was registered earlier). Returns `true` if that handler claimed
* the data; `false` if no handler accepted it.
* - `dataTransfer` is the full {@link DataTransfer} the paste/drop came
* from, so a handler can inspect companion MIME types or attached
* files in addition to the slot it was invoked for (e.g. peek at
* `'application/x-vscode-source'` while handling `'text/html'`). When
* threading through the new pipeline, pass this into
* `$generateNodesFromDOMViaExtension(dom, {
* context: [contextValue(ImportSourceDataTransfer, dataTransfer)],
* })` so rules and preprocessors can read it via
* `ctx.get(ImportSourceDataTransfer)`.
*
* The function should return `true` if it consumed the data (the caller
* stops trying further handlers for this MIME type and does not move on to
* the next MIME type). Return `$next()` to delegate. Return `false` if the
* function decided not to handle the data after inspecting it (e.g. the
* JSON namespace didn't match) so a lower-priority handler — or the next
* MIME type — gets a chance.
*
* @experimental
*/
export type ImportMimeTypeFunction = (
data: string,
selection: BaseSelection,
$next: () => boolean,
dataTransfer: DataTransfer,
) => boolean;
/**
* A mapping from MIME type to a stack of {@link ImportMimeTypeFunction}.
*
* Each entry is an ordered array; the function at the highest index runs
* first and may call `next()` to fall through to the function below it.
* The default config provides one handler each for
* `'application/x-lexical-editor'`, `'text/html'`, and `'text/plain'` that
* matches the legacy {@link $insertDataTransferForRichText} behavior.
*
* When {@link ClipboardImportExtension} merges a partial config, new
* functions are appended to the existing array for each MIME type, so
* later-registered handlers run before earlier ones (including the
* defaults) and may delegate to them via `next()`.
*
* @experimental
*/
export type ImportMimeTypeConfig = {
[key in keyof LexicalClipboardData | (string & {})]?:
| ImportMimeTypeFunction[]
| undefined;
};
/**
* Per-MIME-type ordering weights. Lower numbers run first.
*
* Composable across extensions: each extension contributes weights for
* its MIME types without needing to coordinate. A partial config that
* sets `{'application/vnd.myapp+json': 5}` slots its type between the
* built-in `application/x-lexical-editor` (0) and `text/html` (10) — no
* need to enumerate the full ordering. mergeConfig spreads pairs (later
* keys override earlier ones for the same MIME type, so an extension
* can also re-rank a built-in by repeating its key with a new weight).
*
* Iteration: every MIME type that has a handler stack and is present in
* the dataTransfer (regardless of whether it has an explicit weight) is
* tried; MIME types with no explicit weight sort to the end, behind all
* weighted ones, in lexical order.
*
* @experimental
*/
export type ImportMimeTypePriority = {
readonly [key in keyof LexicalClipboardData | (string & {})]?:
| number
| undefined;
};
/**
* Configuration for {@link ClipboardImportExtension}.
*
* @experimental
*/
export interface ClipboardImportConfig {
/**
* The per-MIME-type deserializer stacks used by
* {@link $insertDataTransferForRichText} when handling a paste or drop
* event.
*
* Merged with `[...prev, ...override]` per MIME type, matching the
* behavior of {@link GetClipboardDataExtension.$exportMimeType}.
*
* Apps add a stack under a brand-new key to register a brand-new MIME
* type. Set a {@link priority} weight to control where in the
* iteration it sits relative to the built-ins.
*/
$importMimeType: ImportMimeTypeConfig;
/**
* See {@link ImportMimeTypePriority}. Spread-merged across configs —
* extensions contribute weights without coordinating with each other.
*/
priority: ImportMimeTypePriority;
}
/**
* Default per-MIME-type weights reproducing the legacy
* `$insertDataTransferForRichText` ordering:
*
* `application/x-lexical-editor` (0) → `text/html` (10) →
* `text/plain` (20) → `text/uri-list` (30).
*
* Gaps between weights let third-party MIME types slot in (e.g. weight
* 5 to run between lexical and html). Apps can also override built-in
* weights to demote them.
*
* @experimental
*/
export const DEFAULT_IMPORT_MIME_TYPE_PRIORITY: ImportMimeTypePriority = {
'application/x-lexical-editor': 0,
'text/html': 10,
'text/plain': 20,
'text/uri-list': 30,
};
function trustHTML(html: string): string | TrustedHTML {
if (window.trustedTypes && window.trustedTypes.createPolicy) {
const policy = window.trustedTypes.createPolicy('lexical', {
createHTML: input => input,
});
return policy.createHTML(html);
}
return html;
}
/**
* Default handler for `'application/x-lexical-editor'`: parse the JSON,
* verify the namespace, and insert the serialized nodes.
*/
const $defaultLexicalEditorImporter: ImportMimeTypeFunction = (
data,
selection,
$next,
) => {
try {
const editor = $getEditor();
const payload = JSON.parse(data);
if (
payload &&
payload.namespace === editor._config.namespace &&
Array.isArray(payload.nodes)
) {
const nodes = $generateNodesFromSerializedNodes(payload.nodes);
$insertGeneratedNodes(editor, nodes, selection);
return true;
}
} catch (error) {
console.error(error);
}
return $next();
};
/**
* Default handler for `'text/html'`: parse the HTML and run the legacy
* `$generateNodesFromDOM`. Override (or stack a higher-priority handler
* on top) to route HTML pastes through {@link DOMImportExtension} or any
* custom pipeline. See {@link $generateNodesFromDOMViaExtension} for the
* built-in `DOMImportExtension` adapter.
*/
const $defaultHtmlImporter: ImportMimeTypeFunction = (
data,
selection,
$next,
) => {
try {
const editor = $getEditor();
const parser = new DOMParser();
const dom = parser.parseFromString(trustHTML(data) as string, 'text/html');
const nodes = $generateNodesFromDOM(editor, dom);
$insertGeneratedNodes(editor, nodes, selection);
return true;
} catch (error) {
console.error(error);
return $next();
}
};
/**
* Default handler for `'text/plain'`. On a RangeSelection, drive the
* insertion off {@link tokenizeRawText} so each `\n` becomes a real
* paragraph break via `insertParagraph` (preserving current text
* format / style on the surrounding `insertText` calls). For other
* selection types, defer to the selection's own `insertRawText`.
*/
const $defaultPlainTextImporter: ImportMimeTypeFunction = (data, selection) => {
if (!$isRangeSelection(selection)) {
selection.insertRawText(data);
return true;
}
const withCurrentRange = (fn: (cur: RangeSelection) => void) => {
const cur = $getSelection();
if ($isRangeSelection(cur)) {
fn(cur);
}
};
tokenizeRawText(data, {
linebreak: () => withCurrentRange(cur => cur.insertParagraph()),
tab: () => withCurrentRange(cur => cur.insertNodes([$createTabNode()])),
text: part => withCurrentRange(cur => cur.insertText(part)),
});
return true;
};
/**
* The default per-MIME-type handler stacks reproducing the legacy
* {@link $insertDataTransferForRichText} behavior exactly. Stacked
* extensions append on top of these.
*
* @experimental
*/
export const DEFAULT_IMPORT_MIME_TYPE: ImportMimeTypeConfig = {
'application/x-lexical-editor': [$defaultLexicalEditorImporter],
'text/html': [$defaultHtmlImporter],
'text/plain': [$defaultPlainTextImporter],
// `text/uri-list` is a Webkit-only payload that drops behave-like text;
// reuse the plain-text handler so a URL drop on a rich-text editor
// inserts as plain text rather than being ignored.
'text/uri-list': [$defaultPlainTextImporter],
};
/**
* Output of {@link ClipboardImportExtension}: the merged configuration
* plus a self-contained {@link $insertDataTransfer} function that owns
* the entire paste-side iteration over the priority list. Apps look this
* up via peer-dependency and call it directly; {@link
* $insertDataTransferForRichText} delegates to it.
*
* @experimental
*/
export interface ClipboardImportOutput extends ClipboardImportConfig {
/**
* Try every MIME type in `priority` order against the `DataTransfer`,
* invoking the configured stack for the first one that has a non-empty
* payload. Returns `true` if any stack claimed the data.
*/
$insertDataTransfer(
dataTransfer: DataTransfer,
selection: BaseSelection,
): boolean;
}
function $callImportMimeTypeFunctionStack(
fns: ImportMimeTypeFunction[] | undefined,
data: string,
selection: BaseSelection,
dataTransfer: DataTransfer,
): boolean {
if (!fns) {
return false;
}
const callAt = (i: number): boolean =>
fns[i]
? fns[i](data, selection, callAt.bind(null, i - 1), dataTransfer)
: false;
return callAt(fns.length - 1);
}
/**
* Sort the MIME types that have a registered handler stack by their
* configured priority weight (ascending). Types with no explicit weight
* sort after all weighted types, in lexical order, so unknown types
* remain reachable but never preempt a known one.
*/
function orderedMimeTypes(config: ClipboardImportConfig): string[] {
const mimes = Object.keys(config.$importMimeType).filter(
k => config.$importMimeType[k] !== undefined,
);
return mimes.sort((a, b) => {
const wa = config.priority[a];
const wb = config.priority[b];
if (wa === undefined && wb === undefined) {
return a < b ? -1 : a > b ? 1 : 0;
}
if (wa === undefined) {
return 1;
}
if (wb === undefined) {
return -1;
}
return wa - wb;
});
}
function $runImport(
config: ClipboardImportConfig,
dataTransfer: DataTransfer,
selection: BaseSelection,
): boolean {
// Read once for the iOS Safari heuristic that skips text/html when it
// matches text/plain verbatim (iOS Safari autocorrect produces a
// text/html payload identical to the plain text).
const plainString = dataTransfer.getData('text/plain');
for (const mime of orderedMimeTypes(config)) {
const data = dataTransfer.getData(mime);
if (!data) {
continue;
}
if (mime === 'text/html' && data === plainString) {
continue;
}
if (
$callImportMimeTypeFunctionStack(
config.$importMimeType[mime],
data,
selection,
dataTransfer,
)
) {
return true;
}
}
return false;
}
const DEFAULT_OUTPUT: ClipboardImportOutput = {
$importMimeType: DEFAULT_IMPORT_MIME_TYPE,
$insertDataTransfer: (dataTransfer, selection) =>
$runImport(
{
$importMimeType: DEFAULT_IMPORT_MIME_TYPE,
priority: DEFAULT_IMPORT_MIME_TYPE_PRIORITY,
},
dataTransfer,
selection,
),
priority: DEFAULT_IMPORT_MIME_TYPE_PRIORITY,
};
/**
* @internal
*
* Look up the {@link ClipboardImportOutput} on the active editor. Returns
* a static default-backed output when no {@link ClipboardImportExtension}
* is configured, so callers can always invoke `output.$insertDataTransfer`
* regardless of whether the editor opted in.
*/
export function $getImportOutput(): ClipboardImportOutput {
const dep = $getPeerDependency<typeof ClipboardImportExtension>(
ClipboardImportExtension.name,
);
return dep ? dep.output : DEFAULT_OUTPUT;
}
/**
* @experimental
*
* Mirror of {@link GetClipboardDataExtension} for the import direction.
* Holds a per-MIME-type stack of {@link ImportMimeTypeFunction}s.
*
* @example
* Route `text/html` pastes through {@link DOMImportExtension}, leaving the
* defaults for other MIME types untouched:
* ```ts
* import {configExtension, defineExtension, $getEditor} from 'lexical';
* import {
* ClipboardImportExtension,
* $insertGeneratedNodes,
* } from '@lexical/clipboard';
* import {
* contextValue,
* DOMImportExtension,
* ImportSource,
* ImportSourceDataTransfer,
* $generateNodesFromDOMViaExtension,
* } from '@lexical/html';
*
* defineExtension({
* name: 'app',
* dependencies: [
* DOMImportExtension,
* configExtension(ClipboardImportExtension, {
* $importMimeType: {
* 'text/html': [
* (html, selection, _$next, dataTransfer) => {
* const parser = new DOMParser();
* const dom = parser.parseFromString(html, 'text/html');
* const nodes = $generateNodesFromDOMViaExtension(dom, {
* context: [
* contextValue(ImportSource, 'paste'),
* contextValue(ImportSourceDataTransfer, dataTransfer),
* ],
* });
* $insertGeneratedNodes($getEditor(), nodes, selection);
* return true;
* },
* ],
* },
* }),
* ],
* });
* ```
*/
export const ClipboardImportExtension = defineExtension({
build: (_editor, config): ClipboardImportOutput => ({
$importMimeType: config.$importMimeType,
$insertDataTransfer: (dataTransfer, selection) =>
$runImport(config, dataTransfer, selection),
priority: config.priority,
}),
config: safeCast<ClipboardImportConfig>({
$importMimeType: DEFAULT_IMPORT_MIME_TYPE,
priority: DEFAULT_IMPORT_MIME_TYPE_PRIORITY,
}),
mergeConfig(config, partial) {
const merged = shallowMergeConfig(config, partial);
if (partial.$importMimeType) {
const $importMimeType: ImportMimeTypeConfig = {...config.$importMimeType};
for (const [k, v] of Object.entries(partial.$importMimeType)) {
if (v) {
const prev = $importMimeType[k];
$importMimeType[k] = prev ? [...prev, ...v] : v;
}
}
merged.$importMimeType = $importMimeType;
}
if (partial.priority) {
// Spread-merge weights. Per-MIME-type keys in `partial` override
// any matching key in `config` (so an extension can rerank a
// built-in MIME type) and new keys are simply added (so multiple
// extensions can each contribute their own MIME types without
// having to coordinate).
merged.priority = {...config.priority, ...partial.priority};
}
return merged;
},
name: '@lexical/clipboard/Import',
});
/**
* @experimental
*
* Drop-in extension that routes `text/html` clipboard pastes and drops
* through the {@link DOMImportExtension} pipeline (rules, schemas,
* preprocessors, overlays) instead of the legacy
* {@link $generateNodesFromDOM}. Add to your extension dependencies along
* with the per-package import extensions you want active
* ({@link CoreImportExtension}, {@link RichTextImportExtension}, etc.).
*
* The original {@link DataTransfer} and `'paste'` source kind are forwarded
* into the import context so rules and preprocessors can read them via
* `ctx.get(ImportSourceDataTransfer)` / `ctx.get(ImportSource)`.
*
* Equivalent to stacking this `text/html` handler manually via
* `configExtension(ClipboardImportExtension, {...})`.
*
* @example
* ```ts
* import {defineExtension} from 'lexical';
* import {ClipboardDOMImportExtension} from '@lexical/clipboard';
* import {CoreImportExtension, RichTextImportExtension} from '@lexical/html';
*
* defineExtension({
* name: 'app',
* dependencies: [
* CoreImportExtension,
* RichTextImportExtension,
* ClipboardDOMImportExtension,
* ],
* });
* ```
*/
export const ClipboardDOMImportExtension = defineExtension({
dependencies: [
configExtension(ClipboardImportExtension, {
$importMimeType: {
'text/html': [
(html, selection, _$next, dataTransfer) => {
const parser = new DOMParser();
const dom = parser.parseFromString(
trustHTML(html) as string,
'text/html',
);
const nodes = $generateNodesFromDOMViaExtension(dom, {
context: [
contextValue(ImportSource, 'paste'),
contextValue(ImportSourceDataTransfer, dataTransfer),
],
});
$insertGeneratedNodes($getEditor(), nodes, selection);
return true;
},
],
},
}),
],
name: '@lexical/clipboard/DOMImport',
});