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).
476 lines (427 loc) • 13.8 kB
JavaScript
import { flatten, isString, isFunction, platform } from '../util'
import DefaultIconProvider from './DefaultIconProvider'
import DefaultLabelProvider from './DefaultLabelProvider'
import SwitchTextTypeCommand from './SwitchTextTypeCommand'
export default class Configurator {
constructor (parent, name) {
this.parent = parent
this.name = name
this._subConfigurations = new Map()
this._values = new Map()
this._commands = new Map()
this._commandGroups = new Map()
this._components = new Map()
this._converters = new Map()
this._documentLoaders = new Map()
this._documentSerializers = new Map()
this._dropHandlers = []
this._exporters = new Map()
this._icons = new Map()
this._importers = new Map()
this._keyboardShortcuts = []
this._keyboardShortcutsByCommandName = new Map()
this._labels = new Map()
this._nodes = new Map()
this._toolPanels = new Map()
this._services = new Map()
// hierarchical registries
this._valuesRegistry = new HierarchicalRegistry(this, c => c._values)
this._commandRegistry = new HierarchicalRegistry(this, c => c._commands)
this._componentRegistry = new HierarchicalRegistry(this, c => c._components)
this._iconRegistry = new HierarchicalRegistry(this, c => c._icons)
this._labelRegistry = new HierarchicalRegistry(this, c => c._labels)
this._serviceRegistry = new HierarchicalRegistry(this, c => c._services)
this._toolPanelRegistry = new HierarchicalRegistry(this, c => c._toolPanels)
this._keyboardShortcutsByCommandNameRegistry = new HierarchicalRegistry(this, c => c._keyboardShortcutsByCommandName)
this._commandGroupRegistry = new HierarchicalRegistry(this, c => c._commandGroups)
}
import (pkg, options) {
pkg.configure(this, options || {})
return this
}
createSubConfiguration (name, options = {}) {
const ConfiguratorClass = options.ConfiguratorClass || this.constructor
const subConfig = new ConfiguratorClass(this, name)
this._subConfigurations.set(name, subConfig)
return subConfig
}
getConfiguration (path) {
// TODO: implement this in a strict way
if (isString(path)) {
path = path.split('.')
}
const subConfig = this._subConfigurations.get(path[0])
if (path.length === 1) {
return subConfig
} else {
if (subConfig) {
return subConfig.getConfiguration(path.slice(1))
}
}
}
getValue (key) {
return this._valuesRegistry.get(key)
}
setValue (key, value) {
this._values.set(key, value)
}
addCommand (name, CommandClass, options = {}) {
if (this._commands.has(name) && !options.force) throw new Error(`Command with name '${name}' already registered`)
this._commands.set(name, new CommandClass(Object.assign({ name }, options)))
if (options.commandGroup) {
this._addCommandToCommandGroup(name, options.commandGroup)
}
if (options.accelerator) {
this.addKeyboardShortcut(options.accelerator, { command: name })
}
}
addComponent (name, ComponentClass, options = {}) {
if (this._components.has(name) && !options.force) throw new Error(`Component with name '${name}' already registered`)
this._components.set(name, ComponentClass)
}
addConverter (format, converter) {
let converters = this._converters.get(format)
if (!converters) {
converters = new Map()
this._converters.set(format, converters)
}
if (isFunction(converter)) {
const ConverterClass = converter
converter = new ConverterClass()
}
if (!converter.type) {
throw new Error('A converter needs an associated type.')
}
converters.set(converter.type, converter)
}
addDropHandler (dropHandler) {
this._dropHandlers.push(dropHandler)
}
addExporter (format, ExporterClass, spec = {}) {
if (this._exporters.has(format)) throw new Error(`Exporter already registered for '${format}'`)
this._exporters.set(format, {
ExporterClass,
spec
})
}
addIcon (iconName, spec, options = {}) {
if (!this._icons.has(iconName)) {
this._icons.set(iconName, {})
}
const iconConfig = this._icons.get(iconName)
for (const type of Object.keys(spec)) {
if (iconConfig[type]) {
if (!options.force) {
throw new Error(`Icon already specified: ${iconName}:${type}`)
}
}
iconConfig[type] = spec[type]
}
}
addImporter (format, ImporterClass, spec = {}) {
if (this._importers.has(format)) throw new Error(`Importer already registered for '${format}'`)
this._importers.set(format, {
ImporterClass,
spec
})
}
addLabel (labelName, label, options = {}) {
if (this._labels.has(labelName) && !options.force) throw new Error(`Label with name '${labelName}' already registered.`)
let labels
if (isString(label)) {
labels = { en: label }
} else {
labels = label
}
this._labels.set(labelName, labels)
}
addNode (NodeClass, options = {}) {
const type = NodeClass.type
if (this._nodes.has(type) && !options.force) {
throw new Error(`Node class for type '${type}' already registered`)
}
this._nodes.set(type, NodeClass)
}
addKeyboardShortcut (combo, spec) {
let label = combo.toUpperCase()
if (platform.isMac) {
label = label.replace(/CommandOrControl/i, '⌘')
label = label.replace(/Ctrl/i, '^')
label = label.replace(/Shift/i, '⇧')
label = label.replace(/Enter/i, '↵')
label = label.replace(/Alt/i, '⌥')
label = label.replace(/\+/g, '')
} else {
label = label.replace(/CommandOrControl/i, 'Ctrl')
}
const entry = {
key: combo,
label,
spec
}
this._keyboardShortcuts.push(entry)
if (spec.command) {
this._keyboardShortcutsByCommandName.set(spec.command, entry)
}
}
// TODO: this should be a helper, if necessary at all
addTextTypeTool (spec) {
this.addCommand(spec.name, SwitchTextTypeCommand, {
spec: spec.nodeSpec,
commandGroup: 'text-types'
})
this.addIcon(spec.name, { fontawesome: spec.icon })
this.addLabel(spec.name, spec.label)
if (spec.accelerator) {
this.addKeyboardShortcut(spec.accelerator, { command: spec.name })
}
}
addToolPanel (name, spec, options = {}) {
if (this._toolPanels.has(name) && !options.force) {
throw new Error(`ToolPanel '${name}' is already defined`)
}
this._toolPanels.set(name, spec)
}
// EXPERIMENTAL: for now we just use a callback as it is the most flexible
// but on the long run I think it would better to restrict this by introducing a DSL
extendToolPanel (name, extensionCb) {
extensionCb(this._toolPanels.get(name))
}
addService (serviceId, factory, options = {}) {
if (this._services.has(serviceId) && !options.force) {
throw new Error(`Service '${serviceId}' is already defined`)
}
this._services.set(serviceId, {
factory,
instance: null
})
}
getService (serviceId, context) {
const entry = this._serviceRegistry.get(serviceId)
if (entry) {
if (entry.instance) {
return Promise.resolve(entry.instance)
} else {
const res = entry.factory(context)
if (res instanceof Promise) {
return res.then(service => {
entry.instance = service
return service
})
} else {
entry.instance = res
return Promise.resolve(res)
}
}
} else {
return Promise.reject(new Error(`Unknown service: ${serviceId}`))
}
}
getServiceSync (serviceId, context) {
const entry = this._serviceRegistry.get(serviceId)
if (entry) {
if (entry && entry.instance) {
return entry.instance
} else {
const service = entry.factory(context)
entry.instance = service
return service
}
}
}
registerDocumentLoader (docType, LoaderClass, spec = {}, options = {}) {
if (this._documentLoaders.has(docType) && !options.force) {
throw new Error(`Loader for docType '${docType}' is already defined`)
}
this._documentLoaders.set(docType, {
LoaderClass,
spec
})
}
registerDocumentSerializer (docType, SerializerClass, spec = {}, options = {}) {
if (this._documentSerializers.has(docType) && !options.force) {
throw new Error(`Serializer for docType '${docType}' is already defined`)
}
this._documentSerializers.set(docType, {
SerializerClass,
spec
})
}
getCommands (options = {}) {
if (options.inherit) {
return this._commandRegistry.getAll()
} else {
return this._commands
}
}
getCommandGroup (name) {
// Note: as commands are registered hierarchically
// we need to collect commands from all levels
const records = this._commandGroupRegistry.getRecords(name)
const flattened = flatten(records)
const set = new Set(flattened)
return Array.from(set)
}
getComponent (name) {
return this.getComponentRegistry().get(name, 'strict')
}
getComponentRegistry () {
return this._componentRegistry
}
getConverters (type) {
if (this._converters.has(type)) {
return Array.from(this._converters.get(type).values())
} else {
return []
}
}
getDocumentLoader (type) {
if (this._documentLoaders.has(type)) {
const { LoaderClass, spec } = this._documentLoaders.get(type)
return new LoaderClass(spec)
}
}
getDocumentSerializer (type) {
if (this._documentSerializers.has(type)) {
const { SerializerClass, spec } = this._documentSerializers.get(type)
return new SerializerClass(spec)
}
}
getIconProvider () {
return new DefaultIconProvider(this)
}
// TODO: the label provider should not be maintained by the configuration
// instead by the app, because language should be part of the app state
getLabelProvider () {
return new LabelProvider(this)
}
createImporter (type, doc, options = {}) {
if (this._importers.has(type)) {
const { ImporterClass, spec } = this._importers.get(type)
let converters = []
if (spec.converterGroups) {
for (const key of spec.converterGroups) {
converters = converters.concat(this.getConverters(key))
}
} else {
converters = this.getConverters(type)
}
return new ImporterClass({ converters }, doc, options, this)
} else if (this.parent) {
return this.parent.createImporter(type, doc, options)
}
}
createExporter (type, doc, options = {}) {
if (this._exporters.has(type)) {
const { ExporterClass, spec } = this._exporters.get(type)
let converters = []
if (spec.converterGroups) {
for (const key of spec.converterGroups) {
converters = converters.concat(this.getConverters(key))
}
} else {
converters = this.getConverters(type)
}
return new ExporterClass({ converters }, doc, options, this)
} else if (this.parent) {
return this.parent.createExporter(type, doc, options)
}
}
getKeyboardShortcuts (options = {}) {
if (options.inherit) {
return Array.from(this._keyboardShortcutsByCommandNameRegistry.getAll().values())
} else {
return this._keyboardShortcuts
}
}
/*
Allows lookup of a keyboard shortcut by command name
*/
getKeyboardShortcutsByCommandName (commandName) {
return this._keyboardShortcutsByCommandNameRegistry.get(commandName)
}
getNodes () {
return this._nodes
}
getToolPanel (name, strict) {
const toolPanelSpec = this._toolPanelRegistry.get(name)
if (toolPanelSpec) {
return toolPanelSpec
} else if (strict) {
throw new Error(`No toolpanel configured with name ${name}`)
}
}
_addCommandToCommandGroup (commandName, commandGroupName) {
if (!this._commandGroups.has(commandGroupName)) {
this._commandGroups.set(commandGroupName, [])
}
const commands = this._commandGroups.get(commandGroupName)
commands.push(commandName)
}
}
class HierarchicalRegistry {
constructor (config, getter) {
this._config = config
this._getter = getter
}
get (name, strict) {
let config = this._config
const getter = this._getter
while (config) {
const registry = getter(config)
if (registry.has(name)) {
return registry.get(name)
} else {
config = config.parent
}
}
if (strict) throw new Error(`No value registered for name '${name}'`)
}
getAll () {
let config = this._config
const registries = []
const getter = this._getter
while (config) {
const registry = getter(config)
if (registry) {
registries.unshift(registry)
}
config = config.parent
}
return new Map([].concat(...registries.map(r => Array.from(r.entries()))))
}
getRecords (name) {
let config = this._config
const records = []
const getter = this._getter
while (config) {
const registry = getter(config)
if (registry) {
const record = registry.get(name)
if (record) {
records.unshift(record)
}
}
config = config.parent
}
return records
}
}
class LabelProvider extends DefaultLabelProvider {
constructor (config) {
super()
this.config = config
}
getLabel (name, params) {
const lang = this.lang
const spec = this.config._labelRegistry.get(name)
if (!spec) return name
const rawLabel = spec[lang] || name
// If context is provided, resolve templates
if (params) {
return this._evalTemplate(rawLabel, params)
} else {
return rawLabel
}
}
}