state.ts (10514B)
1 import {Node, Mark, Schema} from "prosemirror-model" 2 3 import {Selection, TextSelection} from "./selection" 4 import {Transaction} from "./transaction" 5 import {Plugin, StateField} from "./plugin" 6 7 function bind<T extends Function>(f: T, self: any): T { 8 return !self || !f ? f : f.bind(self) 9 } 10 11 class FieldDesc<T> { 12 init: (config: EditorStateConfig, instance: EditorState) => T 13 apply: (tr: Transaction, value: T, oldState: EditorState, newState: EditorState) => T 14 15 constructor(readonly name: string, desc: StateField<any>, self?: any) { 16 this.init = bind(desc.init, self) 17 this.apply = bind(desc.apply, self) 18 } 19 } 20 21 const baseFields = [ 22 new FieldDesc<Node>("doc", { 23 init(config) { return config.doc || config.schema!.topNodeType.createAndFill() }, 24 apply(tr) { return tr.doc } 25 }), 26 27 new FieldDesc<Selection>("selection", { 28 init(config, instance) { return config.selection || Selection.atStart(instance.doc) }, 29 apply(tr) { return tr.selection } 30 }), 31 32 new FieldDesc<readonly Mark[] | null>("storedMarks", { 33 init(config) { return config.storedMarks || null }, 34 apply(tr, _marks, _old, state) { return (state.selection as TextSelection).$cursor ? tr.storedMarks : null } 35 }), 36 37 new FieldDesc<number>("scrollToSelection", { 38 init() { return 0 }, 39 apply(tr, prev) { return tr.scrolledIntoView ? prev + 1 : prev } 40 }) 41 ] 42 43 // Object wrapping the part of a state object that stays the same 44 // across transactions. Stored in the state's `config` property. 45 class Configuration { 46 fields: FieldDesc<any>[] 47 plugins: Plugin[] = [] 48 pluginsByKey: {[key: string]: Plugin} = Object.create(null) 49 50 constructor(readonly schema: Schema, plugins?: readonly Plugin[]) { 51 this.fields = baseFields.slice() 52 if (plugins) plugins.forEach(plugin => { 53 if (this.pluginsByKey[plugin.key]) 54 throw new RangeError("Adding different instances of a keyed plugin (" + plugin.key + ")") 55 this.plugins.push(plugin) 56 this.pluginsByKey[plugin.key] = plugin 57 if (plugin.spec.state) 58 this.fields.push(new FieldDesc<any>(plugin.key, plugin.spec.state, plugin)) 59 }) 60 } 61 } 62 63 /// The type of object passed to 64 /// [`EditorState.create`](#state.EditorState^create). 65 export interface EditorStateConfig { 66 /// The schema to use (only relevant if no `doc` is specified). 67 schema?: Schema 68 69 /// The starting document. Either this or `schema` _must_ be 70 /// provided. 71 doc?: Node 72 73 /// A valid selection in the document. 74 selection?: Selection 75 76 /// The initial set of [stored marks](#state.EditorState.storedMarks). 77 storedMarks?: readonly Mark[] | null 78 79 /// The plugins that should be active in this state. 80 plugins?: readonly Plugin[] 81 } 82 83 /// The state of a ProseMirror editor is represented by an object of 84 /// this type. A state is a persistent data structure—it isn't 85 /// updated, but rather a new state value is computed from an old one 86 /// using the [`apply`](#state.EditorState.apply) method. 87 /// 88 /// A state holds a number of built-in fields, and plugins can 89 /// [define](#state.PluginSpec.state) additional fields. 90 export class EditorState { 91 /// @internal 92 constructor( 93 /// @internal 94 readonly config: Configuration 95 ) {} 96 97 /// The current document. 98 declare doc: Node 99 100 /// The selection. 101 declare selection: Selection 102 103 /// A set of marks to apply to the next input. Will be null when 104 /// no explicit marks have been set. 105 declare storedMarks: readonly Mark[] | null 106 107 /// The schema of the state's document. 108 get schema(): Schema { 109 return this.config.schema 110 } 111 112 /// The plugins that are active in this state. 113 get plugins(): readonly Plugin[] { 114 return this.config.plugins 115 } 116 117 /// Apply the given transaction to produce a new state. 118 apply(tr: Transaction): EditorState { 119 return this.applyTransaction(tr).state 120 } 121 122 /// @internal 123 filterTransaction(tr: Transaction, ignore = -1) { 124 for (let i = 0; i < this.config.plugins.length; i++) if (i != ignore) { 125 let plugin = this.config.plugins[i] 126 if (plugin.spec.filterTransaction && !plugin.spec.filterTransaction.call(plugin, tr, this)) 127 return false 128 } 129 return true 130 } 131 132 /// Verbose variant of [`apply`](#state.EditorState.apply) that 133 /// returns the precise transactions that were applied (which might 134 /// be influenced by the [transaction 135 /// hooks](#state.PluginSpec.filterTransaction) of 136 /// plugins) along with the new state. 137 applyTransaction(rootTr: Transaction): {state: EditorState, transactions: readonly Transaction[]} { 138 if (!this.filterTransaction(rootTr)) return {state: this, transactions: []} 139 140 let trs = [rootTr], newState = this.applyInner(rootTr), seen = null 141 // This loop repeatedly gives plugins a chance to respond to 142 // transactions as new transactions are added, making sure to only 143 // pass the transactions the plugin did not see before. 144 for (;;) { 145 let haveNew = false 146 for (let i = 0; i < this.config.plugins.length; i++) { 147 let plugin = this.config.plugins[i] 148 if (plugin.spec.appendTransaction) { 149 let n = seen ? seen[i].n : 0, oldState = seen ? seen[i].state : this 150 let tr = n < trs.length && 151 plugin.spec.appendTransaction.call(plugin, n ? trs.slice(n) : trs, oldState, newState) 152 if (tr && newState.filterTransaction(tr, i)) { 153 tr.setMeta("appendedTransaction", rootTr) 154 if (!seen) { 155 seen = [] 156 for (let j = 0; j < this.config.plugins.length; j++) 157 seen.push(j < i ? {state: newState, n: trs.length} : {state: this, n: 0}) 158 } 159 trs.push(tr) 160 newState = newState.applyInner(tr) 161 haveNew = true 162 } 163 if (seen) seen[i] = {state: newState, n: trs.length} 164 } 165 } 166 if (!haveNew) return {state: newState, transactions: trs} 167 } 168 } 169 170 /// @internal 171 applyInner(tr: Transaction) { 172 if (!tr.before.eq(this.doc)) throw new RangeError("Applying a mismatched transaction") 173 let newInstance = new EditorState(this.config), fields = this.config.fields 174 for (let i = 0; i < fields.length; i++) { 175 let field = fields[i] 176 ;(newInstance as any)[field.name] = field.apply(tr, (this as any)[field.name], this, newInstance) 177 } 178 return newInstance 179 } 180 181 /// Accessor that constructs and returns a new [transaction](#state.Transaction) from this state. 182 get tr(): Transaction { return new Transaction(this) } 183 184 /// Create a new state. 185 static create(config: EditorStateConfig) { 186 let $config = new Configuration(config.doc ? config.doc.type.schema : config.schema!, config.plugins) 187 let instance = new EditorState($config) 188 for (let i = 0; i < $config.fields.length; i++) 189 (instance as any)[$config.fields[i].name] = $config.fields[i].init(config, instance) 190 return instance 191 } 192 193 /// Create a new state based on this one, but with an adjusted set 194 /// of active plugins. State fields that exist in both sets of 195 /// plugins are kept unchanged. Those that no longer exist are 196 /// dropped, and those that are new are initialized using their 197 /// [`init`](#state.StateField.init) method, passing in the new 198 /// configuration object.. 199 reconfigure(config: { 200 /// New set of active plugins. 201 plugins?: readonly Plugin[] 202 }) { 203 let $config = new Configuration(this.schema, config.plugins) 204 let fields = $config.fields, instance = new EditorState($config) 205 for (let i = 0; i < fields.length; i++) { 206 let name = fields[i].name 207 ;(instance as any)[name] = this.hasOwnProperty(name) ? (this as any)[name] : fields[i].init(config, instance) 208 } 209 return instance 210 } 211 212 /// Serialize this state to JSON. If you want to serialize the state 213 /// of plugins, pass an object mapping property names to use in the 214 /// resulting JSON object to plugin objects. The argument may also be 215 /// a string or number, in which case it is ignored, to support the 216 /// way `JSON.stringify` calls `toString` methods. 217 toJSON(pluginFields?: {[propName: string]: Plugin}): any { 218 let result: any = {doc: this.doc.toJSON(), selection: this.selection.toJSON()} 219 if (this.storedMarks) result.storedMarks = this.storedMarks.map(m => m.toJSON()) 220 if (pluginFields && typeof pluginFields == 'object') for (let prop in pluginFields) { 221 if (prop == "doc" || prop == "selection") 222 throw new RangeError("The JSON fields `doc` and `selection` are reserved") 223 let plugin = pluginFields[prop], state = plugin.spec.state 224 if (state && state.toJSON) result[prop] = state.toJSON.call(plugin, (this as any)[plugin.key]) 225 } 226 return result 227 } 228 229 /// Deserialize a JSON representation of a state. `config` should 230 /// have at least a `schema` field, and should contain array of 231 /// plugins to initialize the state with. `pluginFields` can be used 232 /// to deserialize the state of plugins, by associating plugin 233 /// instances with the property names they use in the JSON object. 234 static fromJSON(config: { 235 /// The schema to use. 236 schema: Schema 237 /// The set of active plugins. 238 plugins?: readonly Plugin[] 239 }, json: any, pluginFields?: {[propName: string]: Plugin}) { 240 if (!json) throw new RangeError("Invalid input for EditorState.fromJSON") 241 if (!config.schema) throw new RangeError("Required config field 'schema' missing") 242 let $config = new Configuration(config.schema, config.plugins) 243 let instance = new EditorState($config) 244 $config.fields.forEach(field => { 245 if (field.name == "doc") { 246 instance.doc = Node.fromJSON(config.schema, json.doc) 247 } else if (field.name == "selection") { 248 instance.selection = Selection.fromJSON(instance.doc, json.selection) 249 } else if (field.name == "storedMarks") { 250 if (json.storedMarks) instance.storedMarks = json.storedMarks.map(config.schema.markFromJSON) 251 } else { 252 if (pluginFields) for (let prop in pluginFields) { 253 let plugin = pluginFields[prop], state = plugin.spec.state 254 if (plugin.key == field.name && state && state.fromJSON && 255 Object.prototype.hasOwnProperty.call(json, prop)) { 256 // This field belongs to a plugin mapped to a JSON field, read it from there. 257 ;(instance as any)[field.name] = state.fromJSON.call(plugin, config, json[prop], instance) 258 return 259 } 260 } 261 ;(instance as any)[field.name] = field.init(config, instance) 262 } 263 }) 264 return instance 265 } 266 }