|
|
- import extend from 'extend';
- import Delta from 'quill-delta';
- import {
- Attributor,
- ClassAttributor,
- EmbedBlot,
- Scope,
- StyleAttributor,
- BlockBlot,
- } from 'parchment';
- import { BlockEmbed } from '../blots/block';
- import Quill from '../core/quill';
- import logger from '../core/logger';
- import Module from '../core/module';
-
- import { AlignAttribute, AlignStyle } from '../formats/align';
- import { BackgroundStyle } from '../formats/background';
- import CodeBlock from '../formats/code';
- import { ColorStyle } from '../formats/color';
- import { DirectionAttribute, DirectionStyle } from '../formats/direction';
- import { FontStyle } from '../formats/font';
- import { SizeStyle } from '../formats/size';
-
- const debug = logger('quill:clipboard');
-
- const CLIPBOARD_CONFIG = [
- [Node.TEXT_NODE, matchText],
- [Node.TEXT_NODE, matchNewline],
- ['br', matchBreak],
- [Node.ELEMENT_NODE, matchNewline],
- [Node.ELEMENT_NODE, matchBlot],
- [Node.ELEMENT_NODE, matchAttributor],
- [Node.ELEMENT_NODE, matchStyles],
- ['li', matchIndent],
- ['ol, ul', matchList],
- ['pre', matchCodeBlock],
- ['tr', matchTable],
- ['b', matchAlias.bind(matchAlias, 'bold')],
- ['i', matchAlias.bind(matchAlias, 'italic')],
- ['style', matchIgnore],
- ];
-
- const ATTRIBUTE_ATTRIBUTORS = [AlignAttribute, DirectionAttribute].reduce(
- (memo, attr) => {
- memo[attr.keyName] = attr;
- return memo;
- },
- {},
- );
-
- const STYLE_ATTRIBUTORS = [
- AlignStyle,
- BackgroundStyle,
- ColorStyle,
- DirectionStyle,
- FontStyle,
- SizeStyle,
- ].reduce((memo, attr) => {
- memo[attr.keyName] = attr;
- return memo;
- }, {});
-
- class Clipboard extends Module {
- constructor(quill, options) {
- super(quill, options);
- this.quill.root.addEventListener('copy', e => this.onCaptureCopy(e, false));
- this.quill.root.addEventListener('cut', e => this.onCaptureCopy(e, true));
- this.quill.root.addEventListener('paste', this.onCapturePaste.bind(this));
- this.matchers = [];
- CLIPBOARD_CONFIG.concat(this.options.matchers).forEach(
- ([selector, matcher]) => {
- this.addMatcher(selector, matcher);
- },
- );
- }
-
- addMatcher(selector, matcher) {
- this.matchers.push([selector, matcher]);
- }
-
- convert({ html, text }, formats = {}) {
- if (formats[CodeBlock.blotName]) {
- return new Delta().insert(text, {
- [CodeBlock.blotName]: formats[CodeBlock.blotName],
- });
- }
- if (!html) {
- return new Delta().insert(text || '');
- }
- const doc = new DOMParser().parseFromString(html, 'text/html');
- const container = doc.body;
- const nodeMatches = new WeakMap();
- const [elementMatchers, textMatchers] = this.prepareMatching(
- container,
- nodeMatches,
- );
- const delta = traverse(
- this.quill.scroll,
- container,
- elementMatchers,
- textMatchers,
- nodeMatches,
- );
- // Remove trailing newline
- if (
- deltaEndsWith(delta, '\n') &&
- (delta.ops[delta.ops.length - 1].attributes == null || formats.table)
- ) {
- return delta.compose(new Delta().retain(delta.length() - 1).delete(1));
- }
- return delta;
- }
-
- dangerouslyPasteHTML(index, html, source = Quill.sources.API) {
- if (typeof index === 'string') {
- const delta = this.convert({ html: index, text: '' });
- this.quill.setContents(delta, html);
- this.quill.setSelection(0, Quill.sources.SILENT);
- } else {
- const paste = this.convert({ html, text: '' });
- this.quill.updateContents(
- new Delta().retain(index).concat(paste),
- source,
- );
- this.quill.setSelection(index + paste.length(), Quill.sources.SILENT);
- }
- }
-
- onCaptureCopy(e, isCut = false) {
- if (e.defaultPrevented) return;
- e.preventDefault();
- const [range] = this.quill.selection.getRange();
- if (range == null) return;
- const { html, text } = this.onCopy(range, isCut);
- e.clipboardData.setData('text/plain', text);
- e.clipboardData.setData('text/html', html);
- if (isCut) {
- this.quill.deleteText(range, Quill.sources.USER);
- }
- }
-
- onCapturePaste(e) {
- if (e.defaultPrevented || !this.quill.isEnabled()) return;
- e.preventDefault();
- const range = this.quill.getSelection(true);
- if (range == null) return;
- const html = e.clipboardData.getData('text/html');
- const text = e.clipboardData.getData('text/plain');
- const files = Array.from(e.clipboardData.files || []);
- if (!html && files.length > 0) {
- this.quill.uploader.upload(range, files);
- } else {
- this.onPaste(range, { html, text });
- }
- }
-
- onCopy(range) {
- const text = this.quill.getText(range);
- const html = this.quill.getSemanticHTML(range);
- return { html, text };
- }
-
- onPaste(range, { text, html }) {
- const formats = this.quill.getFormat(range.index);
- const pastedDelta = this.convert({ text, html }, formats);
- debug.log('onPaste', pastedDelta, { text, html });
- const delta = new Delta()
- .retain(range.index)
- .delete(range.length)
- .concat(pastedDelta);
- this.quill.updateContents(delta, Quill.sources.USER);
- // range.length contributes to delta.length()
- this.quill.setSelection(
- delta.length() - range.length,
- Quill.sources.SILENT,
- );
- this.quill.scrollIntoView();
- }
-
- prepareMatching(container, nodeMatches) {
- const elementMatchers = [];
- const textMatchers = [];
- this.matchers.forEach(pair => {
- const [selector, matcher] = pair;
- switch (selector) {
- case Node.TEXT_NODE:
- textMatchers.push(matcher);
- break;
- case Node.ELEMENT_NODE:
- elementMatchers.push(matcher);
- break;
- default:
- Array.from(container.querySelectorAll(selector)).forEach(node => {
- if (nodeMatches.has(node)) {
- const matches = nodeMatches.get(node);
- matches.push(matcher);
- } else {
- nodeMatches.set(node, [matcher]);
- }
- });
- break;
- }
- });
- return [elementMatchers, textMatchers];
- }
- }
- Clipboard.DEFAULTS = {
- matchers: [],
- };
-
- function applyFormat(delta, format, value) {
- if (typeof format === 'object') {
- return Object.keys(format).reduce((newDelta, key) => {
- return applyFormat(newDelta, key, format[key]);
- }, delta);
- }
- return delta.reduce((newDelta, op) => {
- if (op.attributes && op.attributes[format]) {
- return newDelta.push(op);
- }
- return newDelta.insert(
- op.insert,
- extend({}, { [format]: value }, op.attributes),
- );
- }, new Delta());
- }
-
- function deltaEndsWith(delta, text) {
- let endText = '';
- for (
- let i = delta.ops.length - 1;
- i >= 0 && endText.length < text.length;
- --i // eslint-disable-line no-plusplus
- ) {
- const op = delta.ops[i];
- if (typeof op.insert !== 'string') break;
- endText = op.insert + endText;
- }
- return endText.slice(-1 * text.length) === text;
- }
-
- function isLine(node) {
- if (node.childNodes.length === 0) return false; // Exclude embed blocks
- return [
- 'address',
- 'article',
- 'blockquote',
- 'canvas',
- 'dd',
- 'div',
- 'dl',
- 'dt',
- 'fieldset',
- 'figcaption',
- 'figure',
- 'footer',
- 'form',
- 'h1',
- 'h2',
- 'h3',
- 'h4',
- 'h5',
- 'h6',
- 'header',
- 'iframe',
- 'li',
- 'main',
- 'nav',
- 'ol',
- 'output',
- 'p',
- 'pre',
- 'section',
- 'table',
- 'td',
- 'tr',
- 'ul',
- 'video',
- ].includes(node.tagName.toLowerCase());
- }
-
- const preNodes = new WeakMap();
- function isPre(node) {
- if (node == null) return false;
- if (!preNodes.has(node)) {
- if (node.tagName === 'PRE') {
- preNodes.set(node, true);
- } else {
- preNodes.set(node, isPre(node.parentNode));
- }
- }
- return preNodes.get(node);
- }
-
- function traverse(scroll, node, elementMatchers, textMatchers, nodeMatches) {
- // Post-order
- if (node.nodeType === node.TEXT_NODE) {
- return textMatchers.reduce((delta, matcher) => {
- return matcher(node, delta, scroll);
- }, new Delta());
- }
- if (node.nodeType === node.ELEMENT_NODE) {
- return Array.from(node.childNodes || []).reduce((delta, childNode) => {
- let childrenDelta = traverse(
- scroll,
- childNode,
- elementMatchers,
- textMatchers,
- nodeMatches,
- );
- if (childNode.nodeType === node.ELEMENT_NODE) {
- childrenDelta = elementMatchers.reduce((reducedDelta, matcher) => {
- return matcher(childNode, reducedDelta, scroll);
- }, childrenDelta);
- childrenDelta = (nodeMatches.get(childNode) || []).reduce(
- (reducedDelta, matcher) => {
- return matcher(childNode, reducedDelta, scroll);
- },
- childrenDelta,
- );
- }
- return delta.concat(childrenDelta);
- }, new Delta());
- }
- return new Delta();
- }
-
- function matchAlias(format, node, delta) {
- return applyFormat(delta, format, true);
- }
-
- function matchAttributor(node, delta, scroll) {
- const attributes = Attributor.keys(node);
- const classes = ClassAttributor.keys(node);
- const styles = StyleAttributor.keys(node);
- const formats = {};
- attributes
- .concat(classes)
- .concat(styles)
- .forEach(name => {
- let attr = scroll.query(name, Scope.ATTRIBUTE);
- if (attr != null) {
- formats[attr.attrName] = attr.value(node);
- if (formats[attr.attrName]) return;
- }
- attr = ATTRIBUTE_ATTRIBUTORS[name];
- if (attr != null && (attr.attrName === name || attr.keyName === name)) {
- formats[attr.attrName] = attr.value(node) || undefined;
- }
- attr = STYLE_ATTRIBUTORS[name];
- if (attr != null && (attr.attrName === name || attr.keyName === name)) {
- attr = STYLE_ATTRIBUTORS[name];
- formats[attr.attrName] = attr.value(node) || undefined;
- }
- });
- if (Object.keys(formats).length > 0) {
- return applyFormat(delta, formats);
- }
- return delta;
- }
-
- function matchBlot(node, delta, scroll) {
- const match = scroll.query(node);
- if (match == null) return delta;
- if (match.prototype instanceof EmbedBlot) {
- const embed = {};
- const value = match.value(node);
- if (value != null) {
- embed[match.blotName] = value;
- return new Delta().insert(embed, match.formats(node, scroll));
- }
- } else {
- if (match.prototype instanceof BlockBlot && !deltaEndsWith(delta, '\n')) {
- delta.insert('\n');
- }
- if (typeof match.formats === 'function') {
- return applyFormat(delta, match.blotName, match.formats(node, scroll));
- }
- }
- return delta;
- }
-
- function matchBreak(node, delta) {
- if (!deltaEndsWith(delta, '\n')) {
- delta.insert('\n');
- }
- return delta;
- }
-
- function matchCodeBlock(node, delta, scroll) {
- const match = scroll.query('code-block');
- const language = match ? match.formats(node, scroll) : true;
- return applyFormat(delta, 'code-block', language);
- }
-
- function matchIgnore() {
- return new Delta();
- }
-
- function matchIndent(node, delta, scroll) {
- const match = scroll.query(node);
- if (
- match == null ||
- match.blotName !== 'list' ||
- !deltaEndsWith(delta, '\n')
- ) {
- return delta;
- }
- let indent = -1;
- let parent = node.parentNode;
- while (parent != null) {
- if (['OL', 'UL'].includes(parent.tagName)) {
- indent += 1;
- }
- parent = parent.parentNode;
- }
- if (indent <= 0) return delta;
- return delta.reduce((composed, op) => {
- if (op.attributes && op.attributes.list) {
- return composed.push(op);
- }
- return composed.insert(op.insert, { indent, ...(op.attributes || {}) });
- }, new Delta());
- }
-
- function matchList(node, delta) {
- const list = node.tagName === 'OL' ? 'ordered' : 'bullet';
- return applyFormat(delta, 'list', list);
- }
-
- function matchNewline(node, delta, scroll) {
- if (!deltaEndsWith(delta, '\n')) {
- if (isLine(node)) {
- return delta.insert('\n');
- }
- if (delta.length() > 0 && node.nextSibling) {
- let { nextSibling } = node;
- while (nextSibling != null) {
- if (isLine(nextSibling)) {
- return delta.insert('\n');
- }
- const match = scroll.query(nextSibling);
- if (match && match.prototype instanceof BlockEmbed) {
- return delta.insert('\n');
- }
- nextSibling = nextSibling.firstChild;
- }
- }
- }
- return delta;
- }
-
- function matchStyles(node, delta) {
- const formats = {};
- const style = node.style || {};
- if (style.fontStyle === 'italic') {
- formats.italic = true;
- }
- if (
- style.fontWeight.startsWith('bold') ||
- parseInt(style.fontWeight, 10) >= 700
- ) {
- formats.bold = true;
- }
- if (Object.keys(formats).length > 0) {
- delta = applyFormat(delta, formats);
- }
- if (parseFloat(style.textIndent || 0) > 0) {
- // Could be 0.5in
- return new Delta().insert('\t').concat(delta);
- }
- return delta;
- }
-
- function matchTable(node, delta) {
- const table =
- node.parentNode.tagName === 'TABLE'
- ? node.parentNode
- : node.parentNode.parentNode;
- const rows = Array.from(table.querySelectorAll('tr'));
- const row = rows.indexOf(node) + 1;
- return applyFormat(delta, 'table', row);
- }
-
- function matchText(node, delta) {
- let text = node.data;
- // Word represents empty line with <o:p> </o:p>
- if (node.parentNode.tagName === 'O:P') {
- return delta.insert(text.trim());
- }
- if (text.trim().length === 0 && text.includes('\n')) {
- return delta;
- }
- if (!isPre(node)) {
- const replacer = (collapse, match) => {
- const replaced = match.replace(/[^\u00a0]/g, ''); // \u00a0 is nbsp;
- return replaced.length < 1 && collapse ? ' ' : replaced;
- };
- text = text.replace(/\r\n/g, ' ').replace(/\n/g, ' ');
- text = text.replace(/\s\s+/g, replacer.bind(replacer, true)); // collapse whitespace
- if (
- (node.previousSibling == null && isLine(node.parentNode)) ||
- (node.previousSibling != null && isLine(node.previousSibling))
- ) {
- text = text.replace(/^\s+/, replacer.bind(replacer, false));
- }
- if (
- (node.nextSibling == null && isLine(node.parentNode)) ||
- (node.nextSibling != null && isLine(node.nextSibling))
- ) {
- text = text.replace(/\s+$/, replacer.bind(replacer, false));
- }
- }
- return delta.insert(text);
- }
-
- export {
- Clipboard as default,
- matchAttributor,
- matchBlot,
- matchNewline,
- matchText,
- traverse,
- };
|