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).
837 lines (739 loc) • 25.3 kB
JavaScript
import keys from '../util/keys'
import platform from '../util/platform'
import startsWith from '../util/startsWith'
import { getDOMRangeFromEvent } from '../util/windowUtils'
import DefaultDOMElement from '../dom/DefaultDOMElement'
import Component from '../dom/Component'
import Clipboard from './Clipboard'
import DOMSelection from './DOMSelection'
const BROWSER_DELAY = platform.isFF ? 1 : 0
/**
Abstract interface for editing components.
Dances with contenteditable, so you don't have to.
*/
export default class Surface extends Component {
constructor (...args) {
super(...args)
this._initialize()
}
_initialize () {
const editorSession = this.getEditorSession()
if (!editorSession) throw new Error('editorSession is mandatory')
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 = Surface.createSurfaceId(this)
this.clipboard = this.context.clipboard || this._initializeClipboard()
this.domSelection = this.context.domSelection || this._initializeDOMSelection()
this._state = {
// true if the document session's selection is addressing this surface
skipNextFocusEvent: false
}
}
_initializeClipboard () {
return new Clipboard()
}
_initializeDOMSelection () {
return new DOMSelection(this)
}
getChildContext () {
return {
surface: this,
parentSurfaceId: this.getId(),
doc: this.getDocument(),
// Note: clearing isolatedNodeComponent so that it is easier to detect
// if this surface is within an isolated node or not
isolatedNodeComponent: null
}
}
didMount () {
const surfaceManager = this.getSurfaceManager()
if (surfaceManager && this.isEditable()) {
surfaceManager.registerSurface(this)
}
}
dispose () {
const surfaceManager = this.getSurfaceManager()
// ATTENTION: no matter if registered or not, we always try to unregister
surfaceManager.unregisterSurface(this)
}
didUpdate () {
this._updateContentEditableState()
}
render ($$) {
const tagName = this.props.tagName || 'div'
const el = $$(tagName)
.addClass(this._getClassNames())
.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)
el.on('compositionend', this.onCompositionEnd)
}
// 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.isChromium || platform.isOpera) {
el.on('input', this.onTextInput)
} else {
el.on('keypress', this.onTextInputShim)
}
el.on('copy', this._onCopy)
el.on('paste', this._onPaste)
el.on('cut', this._onCut)
}
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)
// prevent click from bubbling up
el.on('click', this.onClick)
}
}
return el
}
_getClassNames () {
return `sc-surface sm-${this.name}`
}
getName () {
return this.name
}
getId () {
return this._surfaceId
}
getSurfaceId () {
return this._surfaceId
}
isDisabled () {
return this.props.disabled
}
isEditable () {
return !this.isReadonly()
}
isReadonly () {
return (this.props.editable === false || !this.parent.context.editable)
}
getElement () {
return this.el
}
getDocument () {
return this.getEditorSession().getDocument()
}
getComponentRegistry () {
return this.context.componentRegistry
}
getConfig () {
return this.context.config
}
getEditorSession () {
return this.context.editorSession
}
getSurfaceManager () {
return this.context.surfaceManager
}
getGlobalEventHandler () {
return this.context.globalEventHandler
}
getKeyboardManager () {
return this.context.keyboardManager
}
isEnabled () {
return !this.state.disabled
}
isContainerEditor () {
return false
}
isCustomEditor () {
return false
}
hasNativeSpellcheck () {
return this.props.spellcheck === 'native'
}
getContainerPath () {
return null
}
focus () {
const editorSession = this.getEditorSession()
const sel = editorSession.getSelection()
if (sel.surfaceId !== this.getId()) {
this.selectFirst()
}
}
blur () {
const editorSession = this.getEditorSession()
const sel = editorSession.getSelection()
if (sel.surfaceId === this.getId()) {
editorSession.setSelection(null)
}
}
selectFirst () {
// not implemented
}
type (ch) {
const editorSession = this.getEditorSession()
editorSession.transaction((tx) => {
tx.insertText(ch)
}, { action: 'type' })
}
// 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__);
const sel = this.getEditorSession().getSelection()
if (sel.surfaceId === this.getId()) {
this.domSelection.setSelection(sel)
// TODO: remove this HACK
const scrollPane = this.context.scrollPane
if (scrollPane && scrollPane.onSelectionPositioned) {
console.error('DEPRECATED: you should manage the scrollPane yourself')
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(), event);
// ignore fake IME events (emitted in IE and Chromium)
if (event.key === 'Dead') return
// keyboard shortcuts
const keyboardManager = this.getKeyboardManager()
if (!keyboardManager || !keyboardManager.onKeydown(event)) {
// 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)
case keys.SPACE:
return this._handleSpaceKey(event)
default:
break
}
}
}
onTextInput (event) {
if (!this._shouldConsumeEvent(event)) return
// console.log("Surface.onTextInput():", event);
event.preventDefault()
event.stopPropagation()
if (!event.data) return
const ch = event.data
const keyboardManager = this.getKeyboardManager()
if (!keyboardManager || !keyboardManager.onTextInput(ch)) {
this.type(ch)
}
}
// Handling Dead-keys under OSX
onCompositionStart (event) {
if (!this._shouldConsumeEvent(event)) return
// console.log("Surface.onCompositionStart():", event);
// EXPERIMENTAL:
// We need to handle composed characters better
// Here we try to overwrite content which as been already inserted
// e.g. on OSX when holding down `a` a regular text-input event is triggered,
// after a second a context menu appears and a composition-start event is fired
// In that case, the first inserted character must be removed again
if (event.data) {
const editorSession = this.getEditorSession()
const l = event.data.length
const sel = editorSession.getSelection()
if (sel.isPropertySelection() && sel.isCollapsed()) {
// console.log("Overwriting composed character")
const offset = sel.start.offset
editorSession.setSelection(sel.createWithNewRange(offset - l, offset))
}
}
}
onCompositionEnd (event) {
if (!this._shouldConsumeEvent(event)) return
// console.log("Surface.onCompositionEnd():", event);
// Firefox does not fire textinput events at the end of compositions,
// but has providing everything in the compositionend event
if (platform.isFF) {
event.preventDefault()
event.stopPropagation()
if (!event.data) return
this._delayed(() => {
const ch = event.data
const keyboardManager = this.getKeyboardManager()
if (!keyboardManager || !keyboardManager.onTextInput(ch)) {
this.type(ch)
}
})
}
}
// ATTENTION: this is needed for most browsers other than Chrome
// because most of them do not support InputEvent.data (or will maybe never)
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 ch = String.fromCharCode(event.which)
if (!event.shiftKey) {
ch = ch.toLowerCase()
}
event.preventDefault()
event.stopPropagation()
const keyboardManager = this.getKeyboardManager()
if (!keyboardManager || !keyboardManager.onTextInput(ch)) {
if (ch.length > 0) {
this.type(ch)
}
}
}
onClick (event) {
if (!this._shouldConsumeEvent(event)) {
// console.log('skipping mousedown', this.id)
return false
}
// stop bubbling up here
event.stopPropagation()
}
// 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 false
}
// stopping propagation because now the event is considered to be handled
event.stopPropagation()
// 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) {
const 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
// this is important for the regular use case, where the mousup occurs within this component
this.el.on('mouseup', this.onMouseUp, this)
// NOTE: additionally we need to listen to mousup on document to catch events outside the surface
// TODO: it could still be possible not to receive this event, if mouseup is triggered on a component that consumes the event
if (platform.inBrowser) {
const documentEl = DefaultDOMElement.wrapNativeElement(window.document)
documentEl.on('mouseup', this.onMouseUp, this)
}
}
onMouseUp (e) {
// console.log('Surface.onMouseUp', this.id)
this.el.off('mouseup', this.onMouseUp, this)
if (platform.inBrowser) {
const documentEl = DefaultDOMElement.wrapNativeElement(window.document)
documentEl.off('mouseup', this.onMouseUp, this)
}
// 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.
this._delayed(() => {
const sel = this.domSelection.getSelection()
this._setSelection(sel)
})
}
// 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
const sel = this.domSelection.getSelection()
this._setSelection(sel)
}
onNativeBlur () {
// console.log('Native blur on surface', this.getId());
const _state = this._state
_state.hasNativeFocus = false
}
onNativeFocus () {
// console.log('Native focus on surface', this.getId());
const _state = this._state
_state.hasNativeFocus = true
}
_onCopy (e) {
e.preventDefault()
e.stopPropagation()
const clipboardData = e.clipboardData
this.clipboard.copy(clipboardData, this.context)
}
_onCut (e) {
e.preventDefault()
e.stopPropagation()
const clipboardData = e.clipboardData
this.clipboard.cut(clipboardData, this.context)
}
_onPaste (e) {
e.preventDefault()
e.stopPropagation()
const clipboardData = e.clipboardData
// TODO: allow to force plain-text paste
this.clipboard.paste(clipboardData, this.context)
}
// Internal implementations
_onSelectionChanged (selection) {
const 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
const surfaceId = sel.surfaceId
const id = this.getId()
let mode
if (startsWith(surfaceId, id)) {
if (surfaceId.length === id.length) {
mode = 'focused'
} else {
mode = 'co-focused'
}
}
return mode
}
_updateContentEditableState () {
// NOTE: this gets called whenever props or state is updated.
// Particularly, when this surface is co-focused, i.e.
// it has a child surface which is focused, and the child surface
// is inside a ('closed') IsolatedNodeComponent,
// then it is important to turn-off contenteditable, as
// otherwise the cursor can leave the isolated area..
function isInsideOpenIsolatedNode (editorSession, surfaceManager) {
if (surfaceManager) {
const sel = editorSession.getSelection()
const surface = surfaceManager.getSurface(sel.surfaceId)
if (surface) {
const isolatedNodeComponent = surface.context.isolatedNodeComponent
if (isolatedNodeComponent) {
return isolatedNodeComponent.isOpen()
}
}
}
}
// in most cases contenteditable is true if this Surface is not disabled
let enableContenteditable = this.isEditable() && !this.props.disabled
if (enableContenteditable && this.state.mode === 'co-focused') {
enableContenteditable = isInsideOpenIsolatedNode(this.getEditorSession(), this.getSurfaceManager())
}
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({ preventScroll: true })
this._state.skipNextFocusEvent = false
}
}
_handleLeftOrRightArrowKey (event) {
event.stopPropagation()
const 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
this._delayed(() => {
this._updateModelSelection({ direction })
})
}
_handleUpOrDownArrowKey (event) {
event.stopPropagation()
// Note: we need this timeout so that CE updates the DOM selection first
// before we map it to the model
this._delayed(() => {
const options = {
direction: (event.keyCode === keys.UP) ? 'left' : 'right'
}
this._updateModelSelection(options)
})
}
_handleHomeOrEndKey (event) {
event.stopPropagation()
// Note: we need this timeout so that CE updates the DOM selection first
// before we map it to the model
this._delayed(() => {
const options = {
direction: (event.keyCode === keys.HOME) ? 'left' : 'right'
}
this._updateModelSelection(options)
})
}
_handlePageUpOrDownKey (event) {
event.stopPropagation()
// Note: we need this timeout so that CE updates the DOM selection first
// before we map it to the model
this._delayed(() => {
const options = {
direction: (event.keyCode === keys.PAGEUP) ? 'left' : 'right'
}
this._updateModelSelection(options)
})
}
_handleSpaceKey (event) {
event.stopPropagation()
event.preventDefault()
const ch = ' '
const keyboardManager = this.getKeyboardManager()
if (!keyboardManager || !keyboardManager.onTextInput(ch)) {
this.type(ch)
}
}
_handleTabKey (event) {
event.stopPropagation()
this.el.emit('tab', {
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
code: event.code
})
if (this.props.handleTab === false) {
event.preventDefault()
} else {
this.__handleTab(event)
}
}
__handleTab () {
// in many cases we let the browser do the TAB
// and then record the changed selection
this._delayed(() => {
this._updateModelSelection()
})
}
_handleEnterKey (event) {
event.preventDefault()
event.stopPropagation()
this.getEditorSession().transaction((tx) => {
tx.break()
}, { action: 'break' })
}
_handleEscapeKey () {}
_handleDeleteKey (event) {
event.preventDefault()
event.stopPropagation()
const direction = (event.keyCode === keys.BACKSPACE) ? 'left' : 'right'
this.getEditorSession().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.getEditorSession().setSelection(sel)
}
_updateModelSelection (options) {
const 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) {
const doc = this.getDocument()
const text = doc.get(path)
this._setSelection(doc.createSelection({
type: 'property',
path: path,
startOffset: 0,
endOffset: text.length
}))
}
_renderNode ($$, nodeId) {
const doc = this.getDocument()
const node = doc.get(nodeId)
let ComponentClass = this.getComponent(node.type, true)
if (!ComponentClass) {
console.error('Could not resolve a component for type: ' + node.type)
ComponentClass = this.getComponent('unsupported-node')
}
return $$(ComponentClass, this._getNodeProps(node))
}
_getNodeProps (node) {
return {
node,
placeholder: this.props.placeholder,
disabled: this.props.disabled
}
}
// 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)
const comp = Component.unwrap(event.target)
return (comp && (comp === this || comp.context.surface === this))
}
// Used by DragManager
getSelectionFromEvent (event) {
const domRange = getDOMRangeFromEvent(event)
const sel = this.domSelection.getSelectionForDOMRange(domRange)
sel.surfaceId = this.getId()
return sel
}
setSelectionFromEvent (event) {
const 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
}
_delayed (fn) {
if (platform.inBrowser) {
window.setTimeout(fn, BROWSER_DELAY)
}
}
}
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'
*/
Surface.createSurfaceId = function (surface) {
const parentSurfaceId = surface.context.parentSurfaceId
if (parentSurfaceId) {
return parentSurfaceId + '/' + surface.name
} else {
return surface.name
}
}