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.

890 lines (766 loc) 24.5 kB
import { forEach, isPlainObject, isFunction, isString, EventEmitter, uuid } from '../util' import { Selection, SelectionState, ChangeHistory, Transaction, operationHelpers } from '../model' class EditorSession extends EventEmitter { constructor(doc, options) { super() options = options || {} this.__id__ = uuid() this.document = doc const configurator = options.configurator if (!configurator) { throw new Error('No configurator provided.') } this.configurator = configurator this._transaction = new Transaction(doc) // HACK: we want `tx.setSelection()` to add surfaceId to the selection // automatically, so that tx is easier to use. _patchTxSetSelection(this._transaction, this) this._history = new ChangeHistory() // used for change accumulation (in a collab environment) this._currentChange = null // TODO: while it is good to have these selection // related derived state informations separated // it would feel better to have the selection itself // as a property of this session this._selectionState = new SelectionState(doc) this._commandStates = [] // the session exposes these resources, and keeps track of changes this._resources = ['document', 'selection', 'commandStates'] // flags to keep track which resources have changed since the last 'flow' this._dirtyFlags = {} // set during a change this._change = null this._info = null this._flowStages = ['update', 'pre-render', 'render', 'post-render', 'position', 'finalize'] // to get something executed directly after a flow this._postponed = [] this._observers = {} this._lang = options.lang || this.configurator.getDefaultLanguage() this._dir = options.dir || 'ltr' // Managers // -------- const CommandManager = configurator.getCommandManagerClass() const DragManager = configurator.getDragManagerClass() const FileManager = configurator.getFileManagerClass() const GlobalEventHandler = configurator.getGlobalEventHandlerClass() const KeyboardManager = configurator.getKeyboardManagerClass() const MacroManager = configurator.getMacroManagerClass() const MarkersManager = configurator.getMarkersManagerClass() const SurfaceManager = configurator.getSurfaceManagerClass() // surface manager takes care of surfaces, keeps track of the currently focused surface // and makes sure the DOM selection is rendered properly at the end of a flow this.surfaceManager = new SurfaceManager(this) // this context is provided to commands, tools, etc. this._context = { editorSession: this, //legacy surfaceManager: this.surfaceManager, } // to expose custom context just provide optios.context if (options.context) { Object.assign(this._context, options.context) } let commands = configurator.getCommands() let dropHandlers = configurator.getDropHandlers() let macros = configurator.getMacros() let converterRegistry = configurator.getConverterRegistry() let editingBehavior = configurator.getEditingBehavior() this.fileManager = options.fileManager || new FileManager(this, configurator.getFileAdapters(), this._context) // Handling of saving this._hasUnsavedChanges = false this._isSaving = false if (options.saveHandler) { this.saveHandler = options.saveHandler } else { this.saveHandler = configurator.getSaveHandler() } // Custom Managers (registered via configurator e.g. FindAndReplaceManager) this._managers = {} forEach(configurator.getManagers(), (ManagerClass, name) => { this._managers[name] = new ManagerClass(this._context) }) // The command manager keeps the commandStates up-to-date this.commandManager = new CommandManager(this._context, commands) // The drag manager dispatches drag requests to registered drag handlers // TODO: after consolidating the API of this class, we probably need a less diverse context this.dragManager = new DragManager(dropHandlers, Object.assign({}, this._context, { commandManager: this.commandManager })) // The macro manager dispatches to macro detectors at the end of the flow this.macroManager = new MacroManager(this._context, macros) this.globalEventHandler = new GlobalEventHandler(this, this.surfaceManager) this.markersManager = new MarkersManager(this) this.keyboardManager = new KeyboardManager(this, configurator.getKeyboardShortcuts(), { context: this._context }) // TODO: see how we want to expose these this.converterRegistry = converterRegistry this.editingBehavior = editingBehavior } dispose() { this._transaction.dispose() this.surfaceManager.dispose() this.fileManager.dispose() this.commandManager.dispose() this.dragManager.dispose() this.macroManager.dispose() this.globalEventHandler.dispose() this.markersManager.dispose() } hasChanged(resource) { return this._dirtyFlags[resource] } hasDocumentChanged() { return this.hasChanged('document') } hasSelectionChanged() { return this.hasChanged('selection') } hasCommandStatesChanged() { return this.hasChanged('commandStates') } hasLanguageChanged() { return this.hasChanged('lang') } hasTextDirectionChanged() { return this.hasChanged('dir') } get(resourceName) { switch(resourceName) { case 'document': return this.getDocument() case 'selection': return this.getSelection() case 'commandStates': return this.getCommandStates() case 'change': return this.getChange() default: throw new Error('Unknown resource: ' + resourceName) } } getConfigurator() { return this.configurator } getContext() { return this._context } getDocument() { return this.document } getManager(name) { return this._managers[name] } getSelection() { return this.getSelectionState().getSelection() } getSelectionState() { return this._selectionState } getCommandStates() { return this._commandStates } getChange() { return this._change } getChangeInfo() { return this._info } getFocusedSurface() { return this.surfaceManager.getFocusedSurface() } getSurface(surfaceId) { return this.surfaceManager.getSurface(surfaceId) } getLanguage() { return this._lang } getTextDirection() { return this._dir } canUndo() { return this._history.canUndo() } canRedo() { return this._history.canRedo() } executeCommand(...args) { this.commandManager.executeCommand(...args) } /* Set EditorComponent associated with this editorSession */ attachEditor(editor) { this.editor = editor } detachEditor() { this.editor = undefined } getEditor() { return this.editor } setSelection(sel, skipFlow) { // console.log('EditorSession.setSelection()', sel) if (sel && isPlainObject(sel)) { sel = this.getDocument().createSelection(sel) } if (sel && !sel.isNull()) { if (!sel.surfaceId) { let fs = this.getFocusedSurface() if (fs) { sel.surfaceId = fs.id } } } _addSurfaceId(sel, this) _addContainerId(sel, this) if (this._setSelection(sel) && !skipFlow) { this.startFlow() } return sel } selectNode(nodeId) { let surface = this.getFocusedSurface() this.setSelection({ type: 'node', nodeId: nodeId, containerId: surface.getContainerId(), surfaceId: surface.id }) } setCommandStates(commandStates) { this._commandStates = commandStates this._setDirty('commandStates') } setLanguage(lang) { if (this._lang !== lang) { this._lang = lang this._setDirty('lang') this.startFlow() } } setTextDirection(dir) { if (this._dir !== dir) { this._dir = dir this._setDirty('dir') this.startFlow() } } createSelection() { const doc = this.getDocument() return doc.createSelection.apply(doc, arguments) } getCollaborators() { return null } /* Set saveHandler via API E.g. if saveHandler not available at construction */ setSaveHandler(saveHandler) { this.saveHandler = saveHandler } /** Start a transaction to manipulate the document @param {function} transformation a function(tx) that performs actions on the transaction document tx @example ```js doc.transaction(function(tx, args) { tx.update(...) ... tx.setSelection(newSelection) }) ``` */ transaction(transformation, info) { const t = this._transaction info = info || {} t._sync() let change = t._recordChange(transformation, this.getSelection(), this.getFocusedSurface()) if (change) { this._commit(change, info) } else { // if no changes, at least update the selection this._setSelection(this._transaction.getSelection()) this.startFlow() } return change } undo() { this._undoRedo('undo') } redo() { this._undoRedo('redo') } /* eslint-disable no-invalid-this*/ on(...args) { let name = args[0] if (this._flowStages.indexOf(name) >= 0) { // remove the stage name from the args args.shift() let options = args[2] || {} let resource = options.resource if (resource) { delete options.resource args.unshift(resource) } this._registerObserver(name, args) } else { EventEmitter.prototype.on.apply(this, args) } } off(...args) { if (args.length === 1) { let observer = args[0] super.off(...args) // Note: we have stored all registered hooks // on the observer itself if (observer[this.__id__]) { const records = observer[this.__id__] delete observer[this.__id__] records.forEach((record) => { this.__deregisterObserver(record) }) } } else { const stage = args[0] const method = args[1] const observer = args[2] this._deregisterObserver(stage, method, observer) } } /** Registers a hook for the `update` phase. During `update` data should be derived necessary for rendering. This is mainly used by extensions of the EditorSession to derive extra state information. @param {string} [resource] the name of the resource @param {Function} handler the function handler @param {Object} context owner of the handler @param {Object} [options] options for the resource handler */ onUpdate(...args) { return this._registerObserver('update', args) } onPreRender(...args) { return this._registerObserver('pre-render', args) } /** Registers a hook for the 'render' phase. During `render`, components should be rerendered. @param {string} [resource] the name of the resource @param {Function} handler the function handler @param {Object} context owner of the handler @param {Object} [options] options for the resource handler @example This typically used by components that render node content. ```js class ImageComponent extends Component { didMount() { this.context.editorSession.onRender('document', this.rerender, this, { path: [this.props.node.id, 'src'] }) } dispose() { this.context.editorSession.off(this) } render($$) { ... } } ``` */ onRender(...args) { return this._registerObserver('render', args) } /** Registers a hook for the 'post-render' phase. ATM, this phase is used internally only, for recovering the DOM selection which typically gets destroyed due to rerendering @internal @param {string} [resource] the name of the resource @param {Function} handler the function handler @param {Object} context owner of the handler @param {Object} [options] options for the resource handler */ onPostRender(...args) { return this._registerObserver('post-render', args) } /** Registers a hook for the 'position' phase. During `position`, components such as Overlays, for instance, should be positioned. At this stage, it is guaranteed that all content is rendered, and the DOM selection is set. @param {string} [resource] the name of the resource @param {Function} handler the function handler @param {Object} context owner of the handler @param {Object} [options] options for the resource handler */ onPosition(...args) { return this._registerObserver('position', args) } onFinalize(...args) { return this._registerObserver('finalize', args) } _setSelection(sel) { let hasChanged = this.getSelectionState().setSelection(sel) if (hasChanged) this._setDirty('selection') return hasChanged } _undoRedo(which) { const doc = this.getDocument() var from, to if (which === 'redo') { from = this._history.undoneChanges to = this._history.doneChanges } else { from = this._history.doneChanges to = this._history.undoneChanges } var change = from.pop() if (change) { this._applyChange(change, {}) this._transaction.__applyChange__(change) // move change to the opposite change list (undo <-> redo) to.push(change.invert()) // use selection from change let sel = change.after.selection if (sel) sel.attach(doc) this._setSelection(sel) // finally trigger the flow this.startFlow() } else { console.warn('No change can be %s.', (which === 'undo'? 'undone':'redone')) } } _transformLocalChangeHistory(externalChange) { // Transform the change history // Note: using a clone as the transform is done inplace // which is ok for the changes in the undo history, but not // for the external change var clone = { ops: externalChange.ops.map(function(op) { return op.clone(); }) } operationHelpers.transformDocumentChange(clone, this._history.doneChanges) operationHelpers.transformDocumentChange(clone, this._history.undoneChanges) } _transformSelection(change) { var oldSelection = this.getSelection() var newSelection = operationHelpers.transformSelection(oldSelection, change) // console.log('Transformed selection', change, oldSelection.toString(), newSelection.toString()) return newSelection } _commit(change, info) { this._commitChange(change, info) // TODO: Not sure this is the best place to mark the session dirty this._hasUnsavedChanges = true this.startFlow() } _commitChange(change, info) { change.timestamp = Date.now() this._applyChange(change, info) if (info['history'] !== false && !info['hidden']) { this._history.push(change.invert()) } var newSelection = change.after.selection || Selection.nullSelection // HACK injecting the surfaceId here... // TODO: we should find out where the best place is to do this if (!newSelection.isNull() && !newSelection.surfaceId) { newSelection.surfaceId = change.after.surfaceId } this._setSelection(newSelection) } _applyChange(change, info) { if (!change) { console.error('FIXME: change is null.') return } const doc = this.getDocument() doc._apply(change) doc._notifyChangeListeners(change, info) this._setDirty('document') this._change = change this._info = info } /* Are there unsaved changes? */ hasUnsavedChanges() { return this._hasUnsavedChanges } /* Save session / document */ save() { var saveHandler = this.saveHandler if (this._hasUnsavedChanges && !this._isSaving) { this._isSaving = true // Pass saving logic to the user defined callback if available if (saveHandler) { let saveParams = { editorSession: this, fileManager: this.fileManager } return saveHandler.saveDocument(saveParams) .then(() => { this._hasUnsavedChanges = false // We update the selection, just so a selection update flow is // triggered (which will update the save tool) // TODO: model this kind of update more explicitly. It could be an 'update' to the // document resource (hasChanges was modified) this.setSelection(this.getSelection()) }) .catch((err) => { console.error('Error during save', err) }).then(() => { // finally this._isSaving = false }) } else { console.error('Document saving is not handled at the moment. Make sure saveHandler instance provided to editorSession') return Promise.reject() } } } /* Starts the flow. This is necessary when changing resources managed by the session. To be able to change multiple resources at the same time, this is not done automatically, but needs to be called by the implementation. @internal */ startFlow() { if (this._flowing) { throw new Error('Already in a flow. You need to postpone the update.') } this._flowing = true try { this.performFlow() } finally { this._resetFlow() this._flowing = false } // Note: postponing is ATM used only by Macros // HACK: to avoid having multiple flows at the same time // we are running this deferred const postponed = this._postponed const self = this this._postponed = [] setTimeout(function() { postponed.forEach(function(fn) { fn(self) }) }, 0) } /* Emits the phases in the correct order. @internal */ performFlow() { this._flowStages.forEach((stage) => { this._notifyObservers(stage) }) } postpone(fn) { this._postponed.push(fn) } _parseObserverArgs(args) { let params = { stage: null, resource: null, handler: null, context: null, options: {} } // first can be a string let idx = 0 let arg = args[idx] if (isString(arg)) { params.resource = arg idx++ arg = args[idx] } if (!arg) { throw new Error('Provided handler function was nil.') } if (!isFunction(arg)) { throw new Error('Expecting a handler Function.') } params.handler = arg idx++ arg = args[idx] if (arg) { params.context = arg idx++ arg = args[idx] } if (arg) { params.options = arg } return params } // TODO: this needs to be refactored _registerObserver(stage, args) { // this produces a record containing: // { resource, handler, context, options } let record = this._parseObserverArgs(args) record.stage = stage this.__registerObserver(stage, record) } __registerObserver(stage, record) { // HACK: storing the observer record information // in the observer itself if (record.context) { const observer = record.context if (!observer[this.__id__]) { observer[this.__id__] = [] } observer[this.__id__].push(record) } let observers = this._observers[stage] if (!observers) { observers = this._observers[stage] = [] } observers.push(record) } // TODO: this needs to be revisited _deregisterObserver(stage, method, observer) { let self = this // eslint-disable-line no-invalid-this if (arguments.length === 1) { // TODO: we should optimize this, as ATM this needs to traverse // a lot of registered listeners forEach(self._observers, (observers) => { for (let i = observers.length-1; i >=0 ; i--) { const o = observers[i] if (o.context === observer) { observers.splice(i, 1) o._deregistered = true } } }) } else { let observers = self._observers[stage] // if no observers are registered, then this might not // be a deregistration for a stage, but a regular event if (!observers) { EventEmitter.prototype.off.apply(self, arguments) } else { for (let i = observers.length-1; i >= 0; i--) { let o = observers[i] if (o.handler === method && o.context === observer) { observers.splice(i, 1) o._deregistered = true } } } } } __deregisterObserver(record) { const stage = record.stage const observers = this._observers[stage] const observer = record.context const method = record.handler for (let i = observers.length-1; i >= 0; i--) { let o = observers[i] if (o.handler === method && o.context === observer) { observers.splice(i, 1) o._deregistered = true } } } _notifyObservers(stage) { // TODO: this is not hierarchical anymore // i.e. probably we have to expect degradation of performance // with huuuge documents, as the number of listeners is // We could optimize this by 'compiling' a list of observers for // each configuration, maybe lazily // for now we accept this circumstance let _observers = this._observers[stage] if (!_observers) return // Make a copy so that iteration does not get confused, when listeners deregister // TODO: we could improve this by using a custom data structure that allows // manipulation during iteration let observers = _observers.slice() for (let i = 0; i < observers.length; i++) { let o = observers[i] // an observer might have been deregistered while this iteration was going on if (o._deregistered) continue if (!o.resource) { o.handler.call(o.context, this) } else if (o.resource === 'document') { if (!this.hasDocumentChanged()) continue const change = this.getChange() const info = this.getChangeInfo() const path = o.options.path if (!path) { o.handler.call(o.context, change, info, this) } else if (change.hasUpdated(path)) { o.handler.call(o.context, change, info, this) } } else { if (!this.hasChanged(o.resource)) continue const resource = this.get(o.resource) o.handler.call(o.context, resource, this) } } } _setDirty(resource) { this._dirtyFlags[resource] = true } _resetFlow() { Object.keys(this._dirtyFlags).forEach((resource) => { this._dirtyFlags[resource] = false }) this._change = null this._info = null } /* When set to true puts the editor into a blurred state, which means that surface selections are not recovered until blurred state is set to false again. TODO: There are cases where a flow needs to be triggered manually after setting the blurred states in order to rerender the tools (see FindAndReplaceTool._onFocus) */ setBlurred(blurred) { this._blurred = blurred // NOTE: We need to re-evaluate command states when blurred state is changed this.commandManager._updateCommandStates(this) this._setDirty('commandStates') } isBlurred() { return Boolean(this._blurred) } } function _patchTxSetSelection(tx, editorSession) { tx.setSelection = function(sel) { sel = Transaction.prototype.setSelection.call(tx, sel) _addSurfaceId(sel, editorSession) _addContainerId(sel, editorSession) return sel } } /* Complements selection data according to the given Editor state. I.e., if no */ function _addSurfaceId(sel, editorSession) { if (sel && !sel.isNull() && !sel.surfaceId) { // TODO: We could check if the selection is valid within the given surface let surface = editorSession.getFocusedSurface() if (surface) { sel.surfaceId = surface.id } else { // TODO: instead of warning we could try to 'find' a suitable surface. However, this would also be a bit 'magical' console.warn('No focused surface. Selection will not be rendered.') } } } function _addContainerId(sel, editorSession) { if (sel && !sel.isNull() && sel.surfaceId && !sel.containerId) { let surface = editorSession.getSurface(sel.surfaceId) if (surface) { let containerId = surface.getContainerId() if (containerId) { sel.containerId = containerId } } } } export default EditorSession