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 systems.

331 lines (285 loc) 9.87 kB
import { isString, keys } from '../util' import { selectionHelpers, EditingBehavior } from '../model' import Surface from './Surface' import IsolatedNodeComponent from './IsolatedNodeComponent' import RenderingEngine from './RenderingEngine' /** Represents an editor for content rendered in a flow, such as a manuscript. @prop {String} name unique editor name @prop {String} containerId container id @example Create a full-fledged `ContainerEditor` for the `body` container of a document. Allow Strong and Emphasis annotations and to switch text types between paragraph and heading at level 1. ```js $$(ContainerEditor, { name: 'bodyEditor', containerId: 'body' }) ``` */ class ContainerEditor extends Surface { constructor(parent, props, el) { // default props derived from the given props props.containerId = props.containerId || props.node.id props.name = props.name || props.containerId || props.node.id super(parent, props, el) this.containerId = this.props.containerId if (!isString(this.containerId)) { throw new Error("Property 'containerId' is mandatory.") } let doc = this.getDocument() this.container = doc.get(this.containerId) if (!this.container) { throw new Error('Container with id ' + this.containerId + ' does not exist.') } this.editingBehavior = this.context.editingBehavior || new EditingBehavior() this._deriveInternalState(this.props) } // Note: this component is self managed shouldRerender(newProps) { if (newProps.disabled !== this.props.disabled) return true // TODO: we should still detect when the document has changed, // see https://github.com/substance/substance/issues/543 return false } willReceiveProps(newProps) { super.willReceiveProps.apply(this, arguments) this._deriveInternalState(newProps) } didMount() { super.didMount.apply(this, arguments) let editorSession = this.getEditorSession() editorSession.onUpdate('document', this._onContainerChanged, this, { path: this.container.getContentPath() }) this._attachPlaceholder() } dispose() { super.dispose.apply(this, arguments) let editorSession = this.getEditorSession() editorSession.off(this) } render($$) { let el = super.render($$) let doc = this.getDocument() let containerId = this.getContainerId() let containerNode = doc.get(containerId) if (!containerNode) { console.warn('No container node found for ', containerId) } el.addClass('sc-container-editor container-node ' + containerId) .attr("data-id", containerId) // native spellcheck el.attr('spellcheck', this.props.spellcheck === 'native') containerNode.getNodes().forEach(function(node, index) { el.append(this._renderNode($$, node, index)) }.bind(this)) // No editing if disabled by user or container is empty if (!this.props.disabled && !this.isEmpty()) { el.addClass('sm-enabled') el.setAttribute('contenteditable', true) } return el } selectFirst() { const container = this.getContainer() if (container.getLength() > 0) { const editorSession = this.getEditorSession() const first = container.getChildAt(0) selectionHelpers.setCursor(editorSession, first, container.id, 'before') } } _renderNode($$, node, nodeIndex) { let props = { node } if (!node) throw new Error('Illegal argument') if (node.isText()) { return super.renderNode($$, node, nodeIndex) } else { let componentRegistry = this.context.componentRegistry let ComponentClass = componentRegistry.get(node.type) if (ComponentClass.prototype._isCustomNodeComponent || ComponentClass.prototype._isIsolatedNodeComponent) { return $$(ComponentClass, props).ref(node.id) } else { return $$(IsolatedNodeComponent, props).ref(node.id) } } } _deriveInternalState(props) { let _state = this._state if (!props.hasOwnProperty('enabled') || props.enabled) { _state.enabled = true } else { _state.enabled = false } } _selectNextIsolatedNode(direction) { let selState = this.getEditorSession().getSelectionState() let node = (direction === 'left') ? selState.getPreviousNode() : selState.getNextNode() let isIsolatedNode = !node.isText() && !node.isList() if (!node || !isIsolatedNode) return false if ( (direction === 'left' && selState.isFirst()) || (direction === 'right' && selState.isLast()) ) { this.getEditorSession().setSelection({ type: 'node', nodeId: node.id, containerId: selState.getContainer().id, surfaceId: this.id }) return true } return false } _handleLeftOrRightArrowKey(event) { event.stopPropagation() const doc = this.getDocument() const sel = this.getEditorSession().getSelection() const left = (event.keyCode === keys.LEFT) const right = !left const direction = left ? 'left' : 'right' if (sel && !sel.isNull()) { const container = doc.get(sel.containerId, 'strict') // Don't react if we are at the boundary of the document if (sel.isNodeSelection()) { let nodePos = container.getPosition(doc.get(sel.getNodeId())) if ((left && nodePos === 0) || (right && nodePos === container.length-1)) { event.preventDefault() return } } if (sel.isNodeSelection() && !event.shiftKey) { this.domSelection.collapse(direction) } } window.setTimeout(() => { this._updateModelSelection({ direction }) }) } _handleUpOrDownArrowKey(event) { event.stopPropagation() const doc = this.getDocument() const sel = this.getEditorSession().getSelection() const up = (event.keyCode === keys.UP) const down = !up const direction = up ? 'left' : 'right' if (sel && !sel.isNull()) { const container = doc.get(sel.containerId, 'strict') // Don't react if we are at the boundary of the document if (sel.isNodeSelection()) { let nodePos = container.getPosition(doc.get(sel.getNodeId())) if ((up && nodePos === 0) || (down && nodePos === container.length-1)) { event.preventDefault() return } // Unfortunately we need to navigate out of an isolated node // manually, as even Chrome on Win is not able to do it. let editorSession = this.getEditorSession() // TODO the following fixes the mentioned problem for // regular UP/DOWN (non expanding) // For SHIFT+DOWN it happens to work, and only SHIFT-UP when started as NodeSelection needs to be fixed if (!event.shiftKey) { event.preventDefault() if (up) { let prev = container.getChildAt(nodePos-1) selectionHelpers.setCursor(editorSession, prev, sel.containerId, 'after') return } else { let next = container.getChildAt(nodePos+1) selectionHelpers.setCursor(editorSession, next, sel.containerId, 'before') return } } } } window.setTimeout(() => { this._updateModelSelection({ direction }) }) } _handleTabKey(event) { const editorSession = this.getEditorSession() const sel = editorSession.getSelection() if (sel.isNodeSelection() && sel.isFull()) { const comp = this.refs[sel.getNodeId()] if (comp && selectionHelpers.stepIntoIsolatedNode(editorSession, comp)) { event.preventDefault() event.stopPropagation() return } } super._handleTabKey(event) } // Used by Clipboard isContainerEditor() { return true } /** Returns the containerId the editor is bound to */ getContainerId() { return this.containerId } getContainer() { return this.getDocument().get(this.getContainerId()) } isEmpty() { let containerNode = this.getContainer() return (containerNode && containerNode.length === 0) } /* Adds a placeholder if needed */ _attachPlaceholder() { let firstNode = this.childNodes[0] // Remove old placeholder if necessary if (this.placeholderNode) { this.placeholderNode.extendProps({ placeholder: undefined }) } if (this.childNodes.length === 1 && this.props.placeholder) { firstNode.extendProps({ placeholder: this.props.placeholder }) this.placeholderNode = firstNode } } isEditable() { return super.isEditable.call(this) && !this.isEmpty() } // called by flow when subscribed resources have been updated _onContainerChanged(change) { let doc = this.getDocument() // first update the container let renderContext = RenderingEngine.createContext(this) let $$ = renderContext.$$ let container = this.getContainer() let path = container.getContentPath() for (let i = 0; i < change.ops.length; i++) { let op = change.ops[i] if (op.type === "update" && op.path[0] === path[0]) { let diff = op.diff if (diff.type === "insert") { let nodeId = diff.getValue() let node = doc.get(nodeId) let nodeEl if (node) { nodeEl = this._renderNode($$, node) } else { // node does not exist anymore // so we insert a stub element, so that the number of child // elements is consistent nodeEl = $$('div') } this.insertAt(diff.getOffset(), nodeEl) } else if (diff.type === "delete") { this.removeAt(diff.getOffset()) } } } this._attachPlaceholder() } } ContainerEditor.prototype._isContainerEditor = true export default ContainerEditor