You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							358 lines
						
					
					
						
							12 KiB
						
					
					
				
			
		
		
	
	
							358 lines
						
					
					
						
							12 KiB
						
					
					
				import extend from 'extend'; | 
						|
import Delta from 'quill-delta'; | 
						|
import Parchment from 'parchment'; | 
						|
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'; | 
						|
 | 
						|
let debug = logger('quill:clipboard'); | 
						|
 | 
						|
 | 
						|
const DOM_KEY = '__ql-matcher'; | 
						|
 | 
						|
const CLIPBOARD_CONFIG = [ | 
						|
  [Node.TEXT_NODE, matchText], | 
						|
  [Node.TEXT_NODE, matchNewline], | 
						|
  ['br', matchBreak], | 
						|
  [Node.ELEMENT_NODE, matchNewline], | 
						|
  [Node.ELEMENT_NODE, matchBlot], | 
						|
  [Node.ELEMENT_NODE, matchSpacing], | 
						|
  [Node.ELEMENT_NODE, matchAttributor], | 
						|
  [Node.ELEMENT_NODE, matchStyles], | 
						|
  ['li', matchIndent], | 
						|
  ['b', matchAlias.bind(matchAlias, 'bold')], | 
						|
  ['i', matchAlias.bind(matchAlias, 'italic')], | 
						|
  ['style', matchIgnore] | 
						|
]; | 
						|
 | 
						|
const ATTRIBUTE_ATTRIBUTORS = [ | 
						|
  AlignAttribute, | 
						|
  DirectionAttribute | 
						|
].reduce(function(memo, attr) { | 
						|
  memo[attr.keyName] = attr; | 
						|
  return memo; | 
						|
}, {}); | 
						|
 | 
						|
const STYLE_ATTRIBUTORS = [ | 
						|
  AlignStyle, | 
						|
  BackgroundStyle, | 
						|
  ColorStyle, | 
						|
  DirectionStyle, | 
						|
  FontStyle, | 
						|
  SizeStyle | 
						|
].reduce(function(memo, attr) { | 
						|
  memo[attr.keyName] = attr; | 
						|
  return memo; | 
						|
}, {}); | 
						|
 | 
						|
 | 
						|
class Clipboard extends Module { | 
						|
  constructor(quill, options) { | 
						|
    super(quill, options); | 
						|
    this.quill.root.addEventListener('paste', this.onPaste.bind(this)); | 
						|
    this.container = this.quill.addContainer('ql-clipboard'); | 
						|
    this.container.setAttribute('contenteditable', true); | 
						|
    this.container.setAttribute('tabindex', -1); | 
						|
    this.matchers = []; | 
						|
    CLIPBOARD_CONFIG.concat(this.options.matchers).forEach(([selector, matcher]) => { | 
						|
      if (!options.matchVisual && matcher === matchSpacing) return; | 
						|
      this.addMatcher(selector, matcher); | 
						|
    }); | 
						|
  } | 
						|
 | 
						|
  addMatcher(selector, matcher) { | 
						|
    this.matchers.push([selector, matcher]); | 
						|
  } | 
						|
 | 
						|
  convert(html) { | 
						|
    if (typeof html === 'string') { | 
						|
      this.container.innerHTML = html.replace(/\>\r?\n +\</g, '><'); // Remove spaces between tags | 
						|
      return this.convert(); | 
						|
    } | 
						|
    const formats = this.quill.getFormat(this.quill.selection.savedRange.index); | 
						|
    if (formats[CodeBlock.blotName]) { | 
						|
      const text = this.container.innerText; | 
						|
      this.container.innerHTML = ''; | 
						|
      return new Delta().insert(text, { [CodeBlock.blotName]: formats[CodeBlock.blotName] }); | 
						|
    } | 
						|
    let [elementMatchers, textMatchers] = this.prepareMatching(); | 
						|
    let delta = traverse(this.container, elementMatchers, textMatchers); | 
						|
    // Remove trailing newline | 
						|
    if (deltaEndsWith(delta, '\n') && delta.ops[delta.ops.length - 1].attributes == null) { | 
						|
      delta = delta.compose(new Delta().retain(delta.length() - 1).delete(1)); | 
						|
    } | 
						|
    debug.log('convert', this.container.innerHTML, delta); | 
						|
    this.container.innerHTML = ''; | 
						|
    return delta; | 
						|
  } | 
						|
 | 
						|
  dangerouslyPasteHTML(index, html, source = Quill.sources.API) { | 
						|
    if (typeof index === 'string') { | 
						|
      this.quill.setContents(this.convert(index), html); | 
						|
      this.quill.setSelection(0, Quill.sources.SILENT); | 
						|
    } else { | 
						|
      let paste = this.convert(html); | 
						|
      this.quill.updateContents(new Delta().retain(index).concat(paste), source); | 
						|
      this.quill.setSelection(index + paste.length(), Quill.sources.SILENT); | 
						|
    } | 
						|
  } | 
						|
 | 
						|
  onPaste(e) { | 
						|
    if (e.defaultPrevented || !this.quill.isEnabled()) return; | 
						|
    let range = this.quill.getSelection(); | 
						|
    let delta = new Delta().retain(range.index); | 
						|
    let scrollTop = this.quill.scrollingContainer.scrollTop; | 
						|
    this.container.focus(); | 
						|
    this.quill.selection.update(Quill.sources.SILENT); | 
						|
    setTimeout(() => { | 
						|
      delta = delta.concat(this.convert()).delete(range.length); | 
						|
      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.scrollingContainer.scrollTop = scrollTop; | 
						|
      this.quill.focus(); | 
						|
    }, 1); | 
						|
  } | 
						|
 | 
						|
  prepareMatching() { | 
						|
    let elementMatchers = [], textMatchers = []; | 
						|
    this.matchers.forEach((pair) => { | 
						|
      let [selector, matcher] = pair; | 
						|
      switch (selector) { | 
						|
        case Node.TEXT_NODE: | 
						|
          textMatchers.push(matcher); | 
						|
          break; | 
						|
        case Node.ELEMENT_NODE: | 
						|
          elementMatchers.push(matcher); | 
						|
          break; | 
						|
        default: | 
						|
          [].forEach.call(this.container.querySelectorAll(selector), (node) => { | 
						|
            // TODO use weakmap | 
						|
            node[DOM_KEY] = node[DOM_KEY] || []; | 
						|
            node[DOM_KEY].push(matcher); | 
						|
          }); | 
						|
          break; | 
						|
      } | 
						|
    }); | 
						|
    return [elementMatchers, textMatchers]; | 
						|
  } | 
						|
} | 
						|
Clipboard.DEFAULTS = { | 
						|
  matchers: [], | 
						|
  matchVisual: true | 
						|
}; | 
						|
 | 
						|
 | 
						|
function applyFormat(delta, format, value) { | 
						|
  if (typeof format === 'object') { | 
						|
    return Object.keys(format).reduce(function(delta, key) { | 
						|
      return applyFormat(delta, key, format[key]); | 
						|
    }, delta); | 
						|
  } else { | 
						|
    return delta.reduce(function(delta, op) { | 
						|
      if (op.attributes && op.attributes[format]) { | 
						|
        return delta.push(op); | 
						|
      } else { | 
						|
        return delta.insert(op.insert, extend({}, {[format]: value}, op.attributes)); | 
						|
      } | 
						|
    }, new Delta()); | 
						|
  } | 
						|
} | 
						|
 | 
						|
function computeStyle(node) { | 
						|
  if (node.nodeType !== Node.ELEMENT_NODE) return {}; | 
						|
  const DOM_KEY = '__ql-computed-style'; | 
						|
  return node[DOM_KEY] || (node[DOM_KEY] = window.getComputedStyle(node)); | 
						|
} | 
						|
 | 
						|
function deltaEndsWith(delta, text) { | 
						|
  let endText = ""; | 
						|
  for (let i = delta.ops.length - 1; i >= 0 && endText.length < text.length; --i) { | 
						|
    let 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 | 
						|
  let style = computeStyle(node); | 
						|
  return ['block', 'list-item'].indexOf(style.display) > -1; | 
						|
} | 
						|
 | 
						|
function traverse(node, elementMatchers, textMatchers) {  // Post-order | 
						|
  if (node.nodeType === node.TEXT_NODE) { | 
						|
    return textMatchers.reduce(function(delta, matcher) { | 
						|
      return matcher(node, delta); | 
						|
    }, new Delta()); | 
						|
  } else if (node.nodeType === node.ELEMENT_NODE) { | 
						|
    return [].reduce.call(node.childNodes || [], (delta, childNode) => { | 
						|
      let childrenDelta = traverse(childNode, elementMatchers, textMatchers); | 
						|
      if (childNode.nodeType === node.ELEMENT_NODE) { | 
						|
        childrenDelta = elementMatchers.reduce(function(childrenDelta, matcher) { | 
						|
          return matcher(childNode, childrenDelta); | 
						|
        }, childrenDelta); | 
						|
        childrenDelta = (childNode[DOM_KEY] || []).reduce(function(childrenDelta, matcher) { | 
						|
          return matcher(childNode, childrenDelta); | 
						|
        }, childrenDelta); | 
						|
      } | 
						|
      return delta.concat(childrenDelta); | 
						|
    }, new Delta()); | 
						|
  } else { | 
						|
    return new Delta(); | 
						|
  } | 
						|
} | 
						|
 | 
						|
 | 
						|
function matchAlias(format, node, delta) { | 
						|
  return applyFormat(delta, format, true); | 
						|
} | 
						|
 | 
						|
function matchAttributor(node, delta) { | 
						|
  let attributes = Parchment.Attributor.Attribute.keys(node); | 
						|
  let classes = Parchment.Attributor.Class.keys(node); | 
						|
  let styles = Parchment.Attributor.Style.keys(node); | 
						|
  let formats = {}; | 
						|
  attributes.concat(classes).concat(styles).forEach((name) => { | 
						|
    let attr = Parchment.query(name, Parchment.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) { | 
						|
    delta = applyFormat(delta, formats); | 
						|
  } | 
						|
  return delta; | 
						|
} | 
						|
 | 
						|
function matchBlot(node, delta) { | 
						|
  let match = Parchment.query(node); | 
						|
  if (match == null) return delta; | 
						|
  if (match.prototype instanceof Parchment.Embed) { | 
						|
    let embed = {}; | 
						|
    let value = match.value(node); | 
						|
    if (value != null) { | 
						|
      embed[match.blotName] = value; | 
						|
      delta = new Delta().insert(embed, match.formats(node)); | 
						|
    } | 
						|
  } else if (typeof match.formats === 'function') { | 
						|
    delta = applyFormat(delta, match.blotName, match.formats(node)); | 
						|
  } | 
						|
  return delta; | 
						|
} | 
						|
 | 
						|
function matchBreak(node, delta) { | 
						|
  if (!deltaEndsWith(delta, '\n')) { | 
						|
    delta.insert('\n'); | 
						|
  } | 
						|
  return delta; | 
						|
} | 
						|
 | 
						|
function matchIgnore() { | 
						|
  return new Delta(); | 
						|
} | 
						|
 | 
						|
function matchIndent(node, delta) { | 
						|
  let match = Parchment.query(node); | 
						|
  if (match == null || match.blotName !== 'list-item' || !deltaEndsWith(delta, '\n')) { | 
						|
    return delta; | 
						|
  } | 
						|
  let indent = -1, parent = node.parentNode; | 
						|
  while (!parent.classList.contains('ql-clipboard')) { | 
						|
    if ((Parchment.query(parent) || {}).blotName === 'list') { | 
						|
      indent += 1; | 
						|
    } | 
						|
    parent = parent.parentNode; | 
						|
  } | 
						|
  if (indent <= 0) return delta; | 
						|
  return delta.compose(new Delta().retain(delta.length() - 1).retain(1, { indent: indent})); | 
						|
} | 
						|
 | 
						|
function matchNewline(node, delta) { | 
						|
  if (!deltaEndsWith(delta, '\n')) { | 
						|
    if (isLine(node) || (delta.length() > 0 && node.nextSibling && isLine(node.nextSibling))) { | 
						|
      delta.insert('\n'); | 
						|
    } | 
						|
  } | 
						|
  return delta; | 
						|
} | 
						|
 | 
						|
function matchSpacing(node, delta) { | 
						|
  if (isLine(node) && node.nextElementSibling != null && !deltaEndsWith(delta, '\n\n')) { | 
						|
    let nodeHeight = node.offsetHeight + parseFloat(computeStyle(node).marginTop) + parseFloat(computeStyle(node).marginBottom); | 
						|
    if (node.nextElementSibling.offsetTop > node.offsetTop + nodeHeight*1.5) { | 
						|
      delta.insert('\n'); | 
						|
    } | 
						|
  } | 
						|
  return delta; | 
						|
} | 
						|
 | 
						|
function matchStyles(node, delta) { | 
						|
  let formats = {}; | 
						|
  let style = node.style || {}; | 
						|
  if (style.fontStyle && computeStyle(node).fontStyle === 'italic') { | 
						|
    formats.italic = true; | 
						|
  } | 
						|
  if (style.fontWeight && (computeStyle(node).fontWeight.startsWith('bold') || | 
						|
                           parseInt(computeStyle(node).fontWeight) >= 700)) { | 
						|
    formats.bold = true; | 
						|
  } | 
						|
  if (Object.keys(formats).length > 0) { | 
						|
    delta = applyFormat(delta, formats); | 
						|
  } | 
						|
  if (parseFloat(style.textIndent || 0) > 0) {  // Could be 0.5in | 
						|
    delta = new Delta().insert('\t').concat(delta); | 
						|
  } | 
						|
  return delta; | 
						|
} | 
						|
 | 
						|
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 && node.parentNode.classList.contains('ql-clipboard')) { | 
						|
    return delta; | 
						|
  } | 
						|
  if (!computeStyle(node.parentNode).whiteSpace.startsWith('pre')) { | 
						|
    // eslint-disable-next-line func-style | 
						|
    let replacer = function(collapse, match) { | 
						|
      match = match.replace(/[^\u00a0]/g, '');    // \u00a0 is nbsp; | 
						|
      return match.length < 1 && collapse ? ' ' : match; | 
						|
    }; | 
						|
    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, matchSpacing, matchText };
 | 
						|
 |