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.
761 lines (668 loc) • 23 kB
JavaScript
import { keys, platform, startsWith, getDOMRangeFromEvent } from '../util'
import { DefaultDOMElement } from '../dom'
import Component from './Component'
import Clipboard from './Clipboard'
import UnsupportedNode from './UnsupportedNodeComponent'
/**
Abstract interface for editing components.
Dances with contenteditable, so you don't have to.
*/
class Surface extends Component {
constructor(...args) {
super(...args)
// EditorSession instance must be provided either as a prop
// or via dependency-injection
this.editorSession = this.props.editorSession || this.context.editorSession
if (!this.editorSession) {
throw new Error('No EditorSession provided')
}
this.name = this.props.name
if (!this.name) {
throw new Error('Surface must have a name.')
}
if (this.name.indexOf('/') > -1) {
// because we are using '/' to deal with nested surfaces (isolated nodes)
throw new Error("Surface.name must not contain '/'")
}
// this path is an identifier unique for this surface
// considering nesting in IsolatedNodes
this._surfaceId = createSurfaceId(this)
this.clipboard = new Clipboard(this.editorSession, {
converterRegistry: this.context.converterRegistry,
editorOptions: this.editorSession.getConfigurator().getEditorOptions()
})
this.domSelection = this.context.domSelection
if (!this.domSelection) throw new Error('DOMSelection instance must be provided via context.')
this.domObserver = null
// HACK: we need to listen to mousup on document
// to catch events outside the surface
if (platform.inBrowser) {
this.documentEl = DefaultDOMElement.wrapNativeElement(window.document)
}
// set when editing is enabled
this.undoEnabled = true
// a registry for TextProperties which allows us to dispatch changes
this._textProperties = {}
this._state = {
// true if the document session's selection is addressing this surface
skipNextFocusEvent: false
}
}
getChildContext() {
return {
surface: this,
doc: this.getDocument(),
// HACK: clearing isolatedNodeComponent so that we can easily know
// if this surface is within an isolated node
isolatedNodeComponent: null
}
}
didMount() {
if (this.context.surfaceManager) {
this.context.surfaceManager.registerSurface(this)
}
this.editorSession.onRender('selection', this._onSelectionChanged, this)
}
dispose() {
this.editorSession.off(this)
this.clipboard.dispose()
if (this.domObserver) {
this.domObserver.disconnect()
}
if (this.context.surfaceManager) {
this.context.surfaceManager.unregisterSurface(this)
}
}
didUpdate() {
this._updateContentEditableState()
}
render($$) {
let tagName = this.props.tagName || 'div'
let el = $$(tagName)
.addClass('sc-surface')
.attr('tabindex', 2)
.attr('data-surface-id', this.id)
if (!this.isDisabled()) {
if (this.isEditable()) {
// Keyboard Events
el.on('keydown', this.onKeyDown)
// OSX specific handling of dead-keys
if (!platform.isIE) {
el.on('compositionstart', this.onCompositionStart)
}
// Note: TextEvent in Chrome/Webkit is the easiest for us
// as it contains the actual inserted string.
// Though, it is not available in FF and not working properly in IE
// where we fall back to a ContentEditable backed implementation.
if (platform.inBrowser && window.TextEvent && !platform.isIE) {
el.on('textInput', this.onTextInput)
} else {
el.on('keypress', this.onTextInputShim)
}
}
if (!this.isReadonly()) {
// Mouse Events
el.on('mousedown', this.onMouseDown)
el.on('contextmenu', this.onContextMenu)
// disable drag'n'drop
// we will react on this to render a custom selection
el.on('focus', this.onNativeFocus)
el.on('blur', this.onNativeBlur)
// activate the clipboard
this.clipboard.attach(el)
}
}
return el
}
renderNode($$, node) {
let doc = this.getDocument()
let componentRegistry = this.getComponentRegistry()
let ComponentClass = componentRegistry.get(node.type)
if (!ComponentClass) {
console.error('Could not resolve a component for type: ' + node.type)
ComponentClass = UnsupportedNode
}
return $$(ComponentClass, {
placeholder: this.props.placeholder,
doc: doc,
node: node
}).ref(node.id)
}
getComponentRegistry() {
return this.context.componentRegistry || this.props.componentRegistry
}
getName() {
return this.name
}
getId() {
return this._surfaceId
}
isDisabled() {
return this.props.disabled
}
isEditable() {
return (this.props.editing === "full" || this.props.editing === undefined)
}
isSelectable() {
return (this.props.editing === "selection" || this.props.editing === "full")
}
isReadonly() {
return this.props.editing === "readonly"
}
getElement() {
return this.el
}
getDocument() {
return this.editorSession.getDocument()
}
getEditorSession() {
return this.editorSession
}
isEnabled() {
return !this.state.disabled
}
isContainerEditor() {
return false
}
isCustomEditor() {
return false
}
hasNativeSpellcheck() {
return this.props.spellcheck === 'native'
}
getContainerId() {
return null
}
focus() {
if (this.editorSession.getFocusedSurface() !== this) {
this.selectFirst()
}
}
blur() {
if (this.editorSession.getFocusedSurface() === this) {
this.editorSession.setSelection(null)
}
}
selectFirst() {
throw new Error('This method is abstract.')
}
// As the DOMSelection is owned by the Editor now, rerendering could now be done by someone else, e.g. the SurfaceManager?
rerenderDOMSelection() {
if (this.isDisabled()) return
if (platform.inBrowser) {
// console.log('Surface.rerenderDOMSelection', this.__id__);
let sel = this.editorSession.getSelection()
if (sel.surfaceId === this.getId()) {
this.domSelection.setSelection(sel)
// HACK: accessing the scrollpane directly
// TODO: this should be done in a different way
// this will let the scrollpane know that the DOM selection is ready
const scrollPane = this.context.scrollPane
if (scrollPane) {
this.context.scrollPane.onSelectionPositioned()
}
}
}
}
getDomNodeForId(nodeId) {
return this.el.getNativeElement().querySelector('*[data-id="'+nodeId+'"]')
}
/* Event handlers */
/*
* Handle document key down events.
*/
onKeyDown(event) {
if (!this._shouldConsumeEvent(event)) return
// console.log('Surface.onKeyDown()', this.getId());
// ignore fake IME events (emitted in IE and Chromium)
if ( event.key === 'Dead' ) return
// keyboard shortcuts
let custom = this.editorSession.keyboardManager.onKeydown(event)
if (!custom) {
// core handlers for cursor movements and editor interactions
switch ( event.keyCode ) {
// Cursor movements
case keys.LEFT:
case keys.RIGHT:
return this._handleLeftOrRightArrowKey(event)
case keys.UP:
case keys.DOWN:
return this._handleUpOrDownArrowKey(event)
case keys.HOME:
case keys.END:
return this._handleHomeOrEndKey(event)
case keys.PAGEUP:
case keys.PAGEDOWN:
return this._handlePageUpOrDownKey(event)
// Input (together with text-input)
case keys.ENTER:
return this._handleEnterKey(event)
case keys.TAB:
return this._handleTabKey(event)
case keys.BACKSPACE:
case keys.DELETE:
return this._handleDeleteKey(event)
case keys.ESCAPE:
return this._handleEscapeKey(event)
default:
break
}
}
}
onTextInput(event) {
if (!this._shouldConsumeEvent(event)) return
// console.log("TextInput:", event);
event.preventDefault()
event.stopPropagation()
if (!event.data) return
let text = event.data
if (!this.editorSession.keyboardManager.onTextInput(text)) {
this.editorSession.transaction((tx) => {
tx.insertText(text)
}, { action: 'type' })
}
}
// Handling Dead-keys under OSX
onCompositionStart(event) {
if (!this._shouldConsumeEvent(event)) return
}
// TODO: do we need this anymore?
onTextInputShim(event) {
if (!this._shouldConsumeEvent(event)) return
// Filter out non-character keys
if (
// Catches most keys that don't produce output (charCode === 0, thus no character)
event.which === 0 || event.charCode === 0 ||
// Opera 12 doesn't always adhere to that convention
event.keyCode === keys.TAB || event.keyCode === keys.ESCAPE ||
// prevent combinations with meta keys, but not alt-graph which is represented as ctrl+alt
Boolean(event.metaKey) || (Boolean(event.ctrlKey)^Boolean(event.altKey))
) {
return
}
let character = String.fromCharCode(event.which)
if (!event.shiftKey) {
character = character.toLowerCase()
}
event.preventDefault()
event.stopPropagation()
if (!this.editorSession.keyboardManager.onTextInput(character)) {
if (character.length>0) {
this.editorSession.transaction((tx) => {
tx.insertText(character)
}, { action: 'type' })
}
}
}
// TODO: the whole mouse event based selection mechanism needs
// to be redesigned. The current implementation works basically
// though, there are some things which do not work well cross-browser
// particularly, double- and triple clicks.
// also it turned out to be problematic to react on mouse down instantly
onMouseDown(event) {
if (!this._shouldConsumeEvent(event)) {
// console.log('skipping mousedown', this.id)
return
}
// EXPERIMENTAL: trying to 'reserve' a mousedown event
// so that parents know that they shouldn't react
// This is similar to event.stopPropagation() but without
// side-effects.
// Note: some browsers do not do clicks, selections etc. on children if propagation is stopped
if (event.__reserved__) {
// console.log('%s: mousedown already reserved by %s', this.id, event.__reserved__.id)
return
} else {
// console.log('%s: taking mousedown ', this.id)
event.__reserved__ = this
}
// NOTE: this is here to make sure that this surface is contenteditable
// For instance, IsolatedNodeComponent sets contenteditable=false on this element
// to achieve selection isolation
if (this.isEditable()) {
this.el.setAttribute('contenteditable', true)
}
// TODO: what is this exactly?
if ( event.button !== 0 ) {
return
}
// special treatment for triple clicks
if (!(platform.isIE && platform.version<12) && event.detail >= 3) {
let sel = this.getEditorSession().getSelection()
if (sel.isPropertySelection()) {
this._selectProperty(sel.path)
event.preventDefault()
event.stopPropagation()
return
} else if (sel.isContainerSelection()) {
this._selectProperty(sel.startPath)
event.preventDefault()
event.stopPropagation()
return
}
}
// 'mouseDown' is triggered before 'focus' so we tell
// our focus handler that we are already dealing with it
// The opposite situation, when the surface gets focused e.g. using keyboard
// then the handler needs to kick in and recover a persisted selection or such
this._state.skipNextFocusEvent = true
// Bind mouseup to the whole document in case of dragging out of the surface
if (this.documentEl) {
// TODO: we should handle mouse up only if we started a drag (and the selection has really changed)
this.documentEl.on('mouseup', this.onMouseUp, this, { once: true })
}
}
onMouseUp(e) {
// console.log('Surface.onMouseup', this.id);
// ATTENTION: filtering events does not make sense here,
// as we need to make sure that pick the selection even
// when the mouse is released outside the surface
// if (!this._shouldConsumeEvent(e)) return
e.stopPropagation()
// ATTENTION: this delay is necessary for cases the user clicks
// into an existing selection. In this case the window selection still
// holds the old value, and is set to the correct selection after this
// being called.
setTimeout(function() {
let sel = this.domSelection.getSelection()
this._setSelection(sel)
}.bind(this))
}
// When a user right clicks the DOM selection is updated (in Chrome the nearest
// word gets selected). Like we do with the left mouse clicks we need to sync up
// our model selection.
onContextMenu(event) {
if (!this._shouldConsumeEvent(event)) return
let sel = this.domSelection.getSelection()
this._setSelection(sel)
}
onNativeBlur() {
// console.log('Native blur on surface', this.getId());
let _state = this._state
_state.hasNativeFocus = false
}
onNativeFocus() {
// console.log('Native focus on surface', this.getId());
let _state = this._state
_state.hasNativeFocus = true
}
// Internal implementations
_onSelectionChanged(selection) {
let newMode = this._deriveModeFromSelection(selection)
if (this.state.mode !== newMode) {
this.extendState({
mode: newMode
})
}
}
// helper to manage surface mode which is derived from the current selection
_deriveModeFromSelection(sel) {
if (!sel) return null
let surfaceId = sel.surfaceId
let id = this.getId()
let mode
if (startsWith(surfaceId, id)) {
if (surfaceId.length === id.length) {
mode = 'focused'
} else {
mode = 'co-focused'
}
}
return mode
}
_updateContentEditableState() {
// NOTE: managing contenteditable is difficult in
// order to achieve a correct behavior for IsolatedNodes
// For 'closed' isolated nodes it is important that the parents'
// contenteditables are all false. Otherwise, the cursor
// can leave the isolated area.
let enableContenteditable = false
if (this.isEditable() && !this.props.disabled) {
enableContenteditable = true
if (this.state.mode === 'co-focused') {
let selState = this.context.editorSession.getSelectionState()
let sel = selState.getSelection()
let surface = this.context.surfaceManager.getSurface(sel.surfaceId)
if (surface) {
let isolatedNodeComponent = surface.context.isolatedNodeComponent
if (isolatedNodeComponent) {
enableContenteditable = isolatedNodeComponent.isOpen()
}
}
}
}
if (enableContenteditable) {
this.el.setAttribute('contenteditable', true)
} else {
// TODO: find out what is better
this.el.removeAttribute('contenteditable')
}
}
_blur() {
if (this.el) {
this.el.blur()
}
}
_focus() {
if (this.isDisabled()) return
// console.log('Focusing surface %s explicitly with Surface.focus()', this.getId());
// NOTE: FF is causing problems with dynamically activated contenteditables
// and focusing
if (platform.isFF) {
this.domSelection.clear()
this.el.getNativeElement().blur()
}
this._focusElement()
}
_focusElement() {
this._state.hasNativeFocus = true
// HACK: we must not focus explicitly in Chrome/Safari
// as otherwise we get a crazy auto-scroll
// Still, this is ok, as everything is working fine
// there, without that (as opposed to FF/Edge)
if (this.el && !platform.isWebkit) {
this._state.skipNextFocusEvent = true
// ATTENTION: unfortunately, focusing the contenteditable does lead to auto-scrolling
// in some browsers
this.el.focus();
this._state.skipNextFocusEvent = false
}
}
_handleLeftOrRightArrowKey(event) {
event.stopPropagation()
let direction = (event.keyCode === keys.LEFT) ? 'left' : 'right'
// Note: we need this timeout so that CE updates the DOM selection first
// before we map it to the model
window.setTimeout(function() {
this._updateModelSelection({direction})
}.bind(this))
}
_handleUpOrDownArrowKey(event) {
event.stopPropagation()
// Note: we need this timeout so that CE updates the DOM selection first
// before we map it to the model
window.setTimeout(function() {
let options = {
direction: (event.keyCode === keys.UP) ? 'left' : 'right'
}
this._updateModelSelection(options)
}.bind(this));
}
_handleHomeOrEndKey(event) {
event.stopPropagation()
// Note: we need this timeout so that CE updates the DOM selection first
// before we map it to the model
window.setTimeout(function() {
let options = {
direction: (event.keyCode === keys.HOME) ? 'left' : 'right'
}
this._updateModelSelection(options)
}.bind(this))
}
_handlePageUpOrDownKey(event) {
event.stopPropagation()
// Note: we need this timeout so that CE updates the DOM selection first
// before we map it to the model
window.setTimeout(function() {
let options = {
direction: (event.keyCode === keys.PAGEUP) ? 'left' : 'right'
}
this._updateModelSelection(options)
}.bind(this))
}
_handleTabKey(event) {
event.stopPropagation()
if (this.props.handleTab === false) {
event.preventDefault()
this.el.emit('tab', {
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
code: event.code
})
} else {
window.setTimeout(()=>{
this._updateModelSelection()
})
}
}
_handleEnterKey(event) {
event.preventDefault()
event.stopPropagation()
this.editorSession.transaction((tx) => {
tx.break()
}, { action: 'break' })
}
_handleEscapeKey() {}
_handleDeleteKey(event) {
event.preventDefault()
event.stopPropagation()
let direction = (event.keyCode === keys.BACKSPACE) ? 'left' : 'right'
this.editorSession.transaction((tx) => {
tx.deleteCharacter(direction)
}, { action: 'delete' })
}
_hasNativeFocus() {
return Boolean(this._state.hasNativeFocus)
}
_setSelection(sel) {
// Since we allow the surface be blurred natively when clicking
// on tools we now need to make sure that the element is focused natively
// when we set the selection
// This is actually only a problem on FF, other browsers set the focus implicitly
// when a new DOM selection is set.
// ATTENTION: in FF 44 this was causing troubles, making the CE unselectable
// until the next native blur.
// TODO: check if this is still necessary
if (!sel.isNull() && sel.surfaceId === this.id && platform.isFF) {
this._focusElement()
}
this.editorSession.setSelection(sel)
}
_updateModelSelection(options) {
let sel = this.domSelection.getSelection(options)
// console.log('Surface: updating model selection', sel.toString());
// NOTE: this will also lead to a rerendering of the selection
// via session.on('update')
this._setSelection(sel)
}
_selectProperty(path) {
let doc = this.getDocument()
let text = doc.get(path)
this._setSelection(doc.createSelection({
type: 'property',
path: path,
startOffset: 0,
endOffset: text.length
}))
}
// internal API for TextProperties to enable dispatching
// TextProperty components are registered via path
_registerTextProperty(textPropertyComponent) {
let path = textPropertyComponent.getPath()
this._textProperties[path] = textPropertyComponent
}
_unregisterTextProperty(textPropertyComponent) {
let path = textPropertyComponent.getPath()
if (this._textProperties[path] === textPropertyComponent) {
delete this._textProperties[path]
}
}
_getTextPropertyComponent(path) {
return this._textProperties[path]
}
// TODO: we could integrate container node rendering into this helper
// TODO: this helper should be available also in non surface context
_renderNode($$, nodeId) {
let doc = this.getDocument()
let node = doc.get(nodeId)
let componentRegistry = this.context.componentRegistry || this.props.componentRegistry
let ComponentClass = componentRegistry.get(node.type)
if (!ComponentClass) {
console.error('Could not resolve a component for type: ' + node.type)
ComponentClass = UnsupportedNode
}
return $$(ComponentClass, {
doc: doc,
node: node
})
}
// only take care of events which are emitted on targets which belong to this surface
_shouldConsumeEvent(event) {
// console.log('should consume?', event.target, this.id)
let comp = Component.unwrap(event.target)
return (comp && (comp === this || comp.context.surface === this))
}
// Experimental: used by DragManager
getSelectionFromEvent(event) {
let domRange = getDOMRangeFromEvent(event)
let sel = this.domSelection.getSelectionForDOMRange(domRange)
sel.surfaceId = this.getId()
return sel;
}
setSelectionFromEvent(event) {
let sel = this.getSelectionFromEvent(event)
if (sel) {
this._state.skipNextFocusEvent = true
this._setSelection(sel)
} else {
console.error('Could not create a selection from event.');
}
}
get id() {
return this._surfaceId
}
}
Surface.prototype._isSurface = true
/*
Computes the id of a surface
With IsolatedNodes, surfaces can be nested.
In this case the id can be seen as a path from the top-most to the nested ones
@examples
- top-level surface: 'body'
- table cell: 'body/t1/t1-A1.content'
- figure caption: 'body/fig1/fig1-caption.content'
- nested containers: 'body/section1'
*/
function createSurfaceId(surface) {
let isolatedNodeComponent = surface.context.isolatedNodeComponent
if (isolatedNodeComponent) {
let parentSurface = isolatedNodeComponent.context.surface
// nested containers
if (surface.isContainerEditor()) {
if (isolatedNodeComponent._isInlineNodeComponent) {
return parentSurface.id + '/' + isolatedNodeComponent.props.node.id + '/' + surface.name
} else {
return parentSurface.id + '/' + surface.name
}
}
// other isolated nodes such as tables, figures, etc.
else {
return parentSurface.id + '/' + isolatedNodeComponent.props.node.id + '/' + surface.name
}
} else {
return surface.name
}
}
export default Surface