UNPKG

substance

Version:

Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing system. It is developed to power our online editing platform [Substance](http://substance.io).

359 lines (344 loc) 12.7 kB
import map from '../util/map' import last from '../util/last' import uuid from '../util/uuid' import { deepDeleteNode, SNIPPET_ID, TEXT_SNIPPET_ID, removeAt, getContainerPosition, getContainerRoot, insertAt } from './documentHelpers' import { setCursor } from './selectionHelpers' import _transferWithDisambiguatedIds from './_transferWithDisambiguatedIds' /** Pastes clipboard content at the current selection @param {Object} args object with `selection` and `doc` for Substance content or `text` for external HTML content @return {Object} with updated `selection` */ export default function paste (tx, args) { const sel = tx.selection if (!sel || sel.isNull()) { throw new Error('Can not paste without selection.') } if (sel.isCustomSelection()) { throw new Error('Paste not implemented for custom selection.') } args = args || {} args.text = args.text || '' let pasteDoc = args.doc // TODO: is there a better way to detect that this paste is happening within a container? const inContainer = Boolean(sel.containerPath) // first delete the current selection if (!sel.isCollapsed()) { tx.deleteSelection() } // snippet is plain-text only if (!pasteDoc) { // in a PropertyEditor paste the text if (!inContainer) { tx.insertText(args.text) return // in a ContainerEditor interpret line-breaks // and create a document with multiple paragraphs } else { pasteDoc = _convertPlainTextToDocument(tx, args) } } // pasting into a TextProperty const snippet = pasteDoc.get(SNIPPET_ID) let L = snippet.getLength() if (L === 0) return const first = snippet.getNodeAt(0) // paste into a TextProperty if (!inContainer) { // if there is only one node it better be a text node // otherwise we can't do if (L === 1) { if (first.isText()) { _pasteAnnotatedText(tx, pasteDoc) } } else { pasteDoc = _convertIntoAnnotatedText(tx, pasteDoc) _pasteAnnotatedText(tx, pasteDoc) } } else { if (first.isText()) { _pasteAnnotatedText(tx, pasteDoc) // now we remove the first node from the snippet, // so that we can call _pasteDocument for the remaining // content snippet.removeAt(0) L-- } // if still nodes left paste the remaining document if (L > 0) { _pasteDocument(tx, pasteDoc) } } return args } /* Splits plain text by lines and puts them into paragraphs. */ function _convertPlainTextToDocument (tx, args) { const lines = args.text.split(/\s*\n\s*\n/) const pasteDoc = tx.getDocument().newInstance() const defaultTextType = pasteDoc.getSchema().getDefaultTextType() const container = pasteDoc.create({ type: '@container', id: SNIPPET_ID, nodes: [] }) let node if (lines.length === 1) { node = pasteDoc.create({ id: TEXT_SNIPPET_ID, type: defaultTextType, content: lines[0] }) container.append(node.id) } else { for (let i = 0; i < lines.length; i++) { node = pasteDoc.create({ id: uuid(defaultTextType), type: defaultTextType, content: lines[i] }) container.append(node.id) } } return pasteDoc } function _convertIntoAnnotatedText (tx, copy) { const sel = tx.selection const path = sel.start.path const snippet = tx.createSnippet() const defaultTextType = snippet.getSchema().getDefaultTextType() // walk through all nodes const container = copy.get('snippet') const nodeIds = container.getContent() // collect all transformed annotations const fragments = [] let offset = 0 let annos = [] for (const nodeId of nodeIds) { const node = copy.get(nodeId) if (node.isText()) { const text = node.getText() if (fragments.length > 0) { fragments.push(' ') offset += 1 } // tranform annos const _annos = map(node.getAnnotations(), anno => { const data = anno.toJSON() data.start.path = path.slice(0) data.start.offset += offset data.end.offset += offset return data }) fragments.push(text) annos = annos.concat(_annos) offset += text.length } } snippet.create({ id: TEXT_SNIPPET_ID, type: defaultTextType, content: fragments.join('') }) annos.forEach(anno => snippet.create(anno)) snippet.getContainer().append(TEXT_SNIPPET_ID) return snippet } function _pasteAnnotatedText (tx, copy) { const sel = tx.selection const nodes = copy.get(SNIPPET_ID).nodes const firstId = nodes[0] const first = copy.get(firstId) const textPath = first.getPath() const text = copy.get(textPath) const annotations = copy.getIndex('annotations').get(textPath) // insert plain text const path = sel.start.path const offset = sel.start.offset tx.insertText(text) const targetProp = tx.getProperty(path) if (targetProp.isText()) { // copy annotations (only for TEXT properties) let annos = map(annotations) // NOTE: filtering annotations which are not explicitly white-listed via property.targetTypes const allowedTypes = targetProp.targetTypes if (allowedTypes && allowedTypes.size > 0) { annos = annos.filter(anno => allowedTypes.has(anno.type)) } for (const anno of annos) { const data = anno.toJSON() data.start.path = path.slice(0) data.start.offset += offset data.end.offset += offset // create a new uuid if a node with the same id exists already if (tx.get(data.id)) data.id = uuid(data.type) tx.create(data) } } } function _pasteDocument (tx, pasteDoc) { const snippet = pasteDoc.get(SNIPPET_ID) if (snippet.getLength() === 0) return const sel = tx.selection const containerPath = sel.containerPath let insertPos // FIXME: this does not work for lists // IMO we need to add a special implementation for lists // i.e. check if the cursor is inside a list-item, then either break the list if first node is not a list // otherwise merge the list into the current, and if there are more nodes then break the list and proceed on container level if (sel.isPropertySelection()) { const startPath = sel.start.path const node = getContainerRoot(tx, containerPath, sel.start.getNodeId()) // if cursor is in a text node then break the text node // unless it is empty, then we remove the node // and if cursor is at the end we paste the content after the node if (node.isText()) { const startPos = node.getPosition() const text = tx.get(startPath) if (text.length === 0) { insertPos = startPos removeAt(tx, containerPath, insertPos) deepDeleteNode(tx, tx.get(node.id)) } else if (text.length === sel.start.offset) { insertPos = startPos + 1 } else { tx.break() insertPos = startPos + 1 } // Special behavior for lists: // if the first pasted nodes happens to be a list, we merge it into the current list // otherwise we break the list into two lists pasting the remaining content inbetween // unless the list is empty, then we remove it // TODO: try to reuse code for breaking lists from Editing.js } else if (node.isList()) { const list = node const listItem = tx.get(sel.start.getNodeId()) const first = snippet.getNodeAt(0) if (first.isList()) { if (first.getLength() > 0) { const itemPos = listItem.getPosition() if (listItem.getLength() === 0) { // replace the list item with the items from the pasted list removeAt(tx, list.getItemsPath(), itemPos) deepDeleteNode(tx, listItem) _pasteListItems(tx, list, first, itemPos) } else if (sel.start.offset === 0) { // insert items before the current list item _pasteListItems(tx, list, first, itemPos) } else if (sel.start.offset >= listItem.getLength()) { // insert items after the current list item _pasteListItems(tx, list, first, itemPos + 1) } else { tx.break() _pasteListItems(tx, list, first, itemPos + 1) } // if there is more content than just the list, // break the list apart if (snippet.getLength() > 1) { _breakListApart(tx, containerPath, list) } } // remove the first and continue with pasting the remaining content after the current list snippet.removeAt(0) insertPos = list.getPosition() + 1 } else { // if the list is empty then remove it if (list.getLength() === 1 && listItem.getLength() === 0) { insertPos = list.getPosition() removeAt(tx, containerPath, insertPos) deepDeleteNode(tx, list) // if on first position of list, paste all content before the list } else if (listItem.getPosition() === 0 && sel.start.offset === 0) { insertPos = list.getPosition() // if cursor is at the last position of the list paste all content after the list } else if (listItem.getPosition() === list.getLength() - 1 && sel.end.offset >= listItem.getLength()) { insertPos = list.getPosition() + 1 // break the list at the current position (splitting) } else { insertPos = list.getPosition() + 1 _breakListApart(tx, containerPath, list) } } } } else if (sel.isNodeSelection()) { const nodePos = getContainerPosition(tx, containerPath, sel.getNodeId()) if (sel.isBefore()) { insertPos = nodePos } else if (sel.isAfter()) { insertPos = nodePos + 1 } else { throw new Error('Illegal state: the selection should be collapsed.') } } _pasteContainerNodes(tx, pasteDoc, containerPath, insertPos) } function _pasteContainerNodes (tx, pasteDoc, containerPath, insertPos) { // transfer nodes from content document const nodeIds = pasteDoc.get(SNIPPET_ID).nodes const insertedNodes = [] const visited = {} let nodes = nodeIds.map(id => pasteDoc.get(id)) // now filter nodes w.r.t. allowed types for the given container const containerProperty = tx.getProperty(containerPath) const targetTypes = containerProperty.targetTypes // TODO: instead of dropping all invalid ones we could try to convert text nodes to the default text node if (targetTypes && targetTypes.size > 0) { nodes = nodes.filter(node => targetTypes.has(node.type)) } for (let node of nodes) { // Note: this will on the one hand make sure node ids are changed // to avoid collisions in the target doc // Plus, it uses reflection to create owned nodes recursively, // and to transfer attached annotations. const newId = _transferWithDisambiguatedIds(node.getDocument(), tx, node.id, visited) // get the node in the targetDocument node = tx.get(newId) insertAt(tx, containerPath, insertPos++, newId) insertedNodes.push(node) } if (insertedNodes.length > 0) { const lastNode = last(insertedNodes) setCursor(tx, lastNode, containerPath, 'after') } } function _pasteListItems (tx, list, otherList, insertPos) { const sel = tx.getSelection() const items = otherList.resolve('items') const visited = {} let lastItem for (const item of items) { const newId = _transferWithDisambiguatedIds(item.getDocument(), tx, item.id, visited) insertAt(tx, list.getItemsPath(), insertPos++, newId) lastItem = tx.get(newId) } tx.setSelection({ type: 'property', path: lastItem.getPath(), startOffset: lastItem.getLength(), surfaceId: sel.surfaceId, containerPath: sel.containerPath }) } function _breakListApart (tx, containerPath, list) { // HACK: using tx.break() to break the list const nodePos = list.getPosition() // first split the current item with a break const oldSel = tx.selection tx.break() const listItem = tx.get(tx.selection.start.getNodeId()) // if the list item is empty, another tx.break() splits the list // otherwise doing the same again if (listItem.getLength() > 0) { tx.setSelection(oldSel) tx.break() } console.assert(tx.get(tx.selection.start.getNodeId()).getLength() === 0, 'at this point the current list-item should be empty') // breaking a list on an empty list-item breaks the list apart // but this creates an empty paragraph which we need to removed // TODO: maybe we should add an option to tx.break() that allows break without insert of empty text node tx.break() const p = removeAt(tx, containerPath, nodePos + 1) deepDeleteNode(tx, p) }