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

379 lines (338 loc) 11.5 kB
import isArray from '../util/isArray' import isArrayEqual from '../util/isArrayEqual' import _isDefined from '../util/_isDefined' import keys from '../util/keys' import RenderingEngine from '../dom/RenderingEngine' import * as selectionHelpers from '../model/selectionHelpers' import { getContainerPosition } from '../model/documentHelpers' import Surface from './Surface' /** * Represents an editor for content rendered in a flow, such as a manuscript. * * @prop {String} name unique editor name * @prop {String} containerPath 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', * containerPath: ['body', 'nodes'] * }) * ``` */ export default class ContainerEditor extends Surface { constructor (parent, props, el) { // TODO consolidate this - how is it used actually? props.containerPath = props.containerPath || props.node.getContentPath() props.name = props.name || props.containerPath.join('.') || props.node.id super(parent, props, el) } _initialize () { super._initialize() this.containerPath = this.props.containerPath if (!isArray(this.containerPath)) { throw new Error("Property 'containerPath' is mandatory.") } 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() const editorState = this.context.editorSession.getEditorState() editorState.addObserver(['selection'], this._onSelectionChanged, this, { stage: 'render' }) editorState.addObserver(['document'], this._onContainerChanged, this, { stage: 'render', document: { path: this.containerPath } }) } dispose () { super.dispose() const editorState = this.context.editorSession.getEditorState() editorState.removeObserver(this) } render ($$) { const el = super.render($$) const doc = this.getDocument() const containerPath = this.getContainerPath() el.attr('data-id', containerPath.join('.')) // native spellcheck el.attr('spellcheck', this.props.spellcheck === 'native') const ids = doc.get(containerPath) el.append( ids.map((id, index) => { return this._renderNode($$, doc.get(id), index) }) ) // No editing if disabled by user or container is empty if (!this.props.disabled && !this.isEmpty()) { el.addClass('sm-enabled') el.setAttribute('contenteditable', true) } if (this.isEditable()) { el.addClass('sm-editable') } else { el.addClass('sm-readonly') // HACK: removing contenteditable if not editable // TODO: we should fix substance.TextPropertyEditor to be consistent with props used in substance.Surface el.setAttribute('contenteditable', false) } return el } _getClassNames () { return 'sc-container-editor sc-surface' } selectFirst () { const doc = this.getDocument() const containerPath = this.getContainerPath() const nodeIds = doc.get() if (nodeIds.length > 0) { const editorSession = this.getEditorSession() const first = doc.get(nodeIds[0]) selectionHelpers.setCursor(editorSession, first, containerPath, 'before') } } _renderNode ($$, node, nodeIndex) { if (!node) throw new Error('Illegal argument') const ComponentClass = this._getNodeComponentClass(node) const props = this._getNodeProps(node) return $$(ComponentClass, props).ref(node.id) } _getNodeComponentClass (node) { const ComponentClass = this.getComponent(node.type, 'not-strict') if (ComponentClass) { // text components are used directly if (node.isText() || this.props.disabled) { return ComponentClass // other components are wrapped into an IsolatedNodeComponent // except the component is itself a customized IsolatedNodeComponent } else if (ComponentClass.prototype._isCustomNodeComponent || ComponentClass.prototype._isIsolatedNodeComponent) { return ComponentClass } else { return this.getComponent('isolated-node') } } else { // for text nodes without an component registered explicitly // we use the default text component if (node.isText()) { return this.getComponent('text-node') // otherwise component for unsupported nodes } else { return this.getComponent('unsupported-node') } } } _deriveInternalState (props) { const _state = this._state if (!_isDefined(props.enabled) || props.enabled) { _state.enabled = true } else { _state.enabled = false } } _selectNextIsolatedNode (direction) { const selState = this.getEditorSession().getSelectionState() const node = (direction === 'left') ? selState.previousNode : selState.nextNode const 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, containerPath: this.getContainerPath(), surfaceId: this.id }) return true } return false } _softBreak () { const editorSession = this.getEditorSession() const sel = editorSession.getSelection() if (sel.isPropertySelection()) { editorSession.transaction(tx => { tx.insertText('\n') }, { action: 'soft-break' }) } else { editorSession.transaction((tx) => { tx.break() }, { action: 'break' }) } } _handleEnterKey (event) { // for SHIFT-ENTER a line break is inserted (<break> if allowed, or \n alternatively) if (event.shiftKey) { event.preventDefault() event.stopPropagation() this._softBreak() } else { super._handleEnterKey(event) } } _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 containerPath = sel.containerPath // Don't react if we are at the boundary of the document if (sel.isNodeSelection()) { const nodeIds = doc.get(containerPath) const nodePos = getContainerPosition(doc, containerPath, sel.getNodeId()) if ((left && nodePos === 0) || (right && nodePos === nodeIds.length - 1)) { event.preventDefault() return } } if (sel.isNodeSelection() && !event.shiftKey) { this.domSelection.collapse(direction) } } this._delayed(() => { 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 containerPath = sel.containerPath // Don't react if we are at the boundary of the document if (sel.isNodeSelection()) { const nodeIds = doc.get(containerPath) const nodePos = getContainerPosition(doc, containerPath, sel.getNodeId()) if ((up && nodePos === 0) || (down && nodePos === nodeIds.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. const 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) { const prev = doc.get(nodeIds[nodePos - 1]) selectionHelpers.setCursor(editorSession, prev, containerPath, 'after') return } else { const next = doc.get(nodeIds[nodePos + 1]) selectionHelpers.setCursor(editorSession, next, containerPath, 'before') return } } } } this._delayed(() => { this._updateModelSelection({ direction }) }) } _handleTabKey (event) { const editorSession = this.getEditorSession() const sel = editorSession.getSelection() // EXPERIMENTAL: using TAB to enter an isolated node 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) } __handleTab (e) { e.preventDefault() if (e.shiftKey) { this.getEditorSession().transaction((tx) => { tx.dedent() }, { action: 'dedent' }) } else { this.getEditorSession().transaction((tx) => { tx.indent() }, { action: 'indent' }) } } // Used by Clipboard isContainerEditor () { return true } /** Returns the containerPath the editor is bound to */ getContainerPath () { return this.containerPath } isEmpty () { const ids = this.getDocument().get(this.containerPath) return (!ids || ids.length === 0) } isEditable () { return super.isEditable.call(this) && !this.isEmpty() } // called by flow when subscribed resources have been updated _onContainerChanged (change) { const doc = this.getDocument() // first update the container const renderContext = RenderingEngine.createContext(this) const $$ = renderContext.$$ const containerPath = this.getContainerPath() for (const op of change.primitiveOps) { if (isArrayEqual(op.path, containerPath)) { if (op.type === 'update') { const diff = op.diff if (diff.type === 'insert') { const nodeId = diff.getValue() const 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()) } } else { this.empty() this.rerender() } } } } get _isContainerEditor () { return true } }