diff --git a/commands/src/commands.ts b/commands/src/commands.ts index ea521d61..f9446eea 100644 --- a/commands/src/commands.ts +++ b/commands/src/commands.ts @@ -1,4 +1,4 @@ -import {EditorSelection, SelectionRange, MetaSlot} from "../../state/src" +import {EditorState, EditorSelection, SelectionRange, MetaSlot, StateExtension} from "../../state/src" import {EditorView} from "../../view/src" export type Command = (view: EditorView) => boolean @@ -86,6 +86,58 @@ function deleteText(view: EditorView, dir: "forward" | "backward") { export const deleteCharBackward: Command = view => deleteText(view, "backward") export const deleteCharForward: Command = view => deleteText(view, "forward") +// FIXME support indenting by tab, configurable indent units + +function space(n: number) { + let result = "" + for (let i = 0; i < n; i++) result += " " + return result +} + +function getIndentation(state: EditorState, pos: number): number { + for (let f of state.behavior.get(StateExtension.indentation)) { + let result = f(state, pos) + if (result > -1) return result + } + return -1 +} + +export function insertNewlineAndIndent({state, dispatch}: EditorView): boolean { + let indentation = state.selection.ranges.map(r => getIndentation(state, r.from)), i = 0 + dispatch(state.transaction.reduceRanges((tr, range) => { + let indent = indentation[i++] + return {transaction: tr.replace(range.from, range.to, ["", space(indent)]), + range: new SelectionRange(range.from + indent + 1)} + }).scrollIntoView()) + return true +} + +export function indentSelection({state, dispatch}: EditorView): boolean { + let lastLine = -1, positions = [] + for (let range of state.selection.ranges) { + for (let {start, end} = state.doc.lineAt(range.from);;) { + if (start != lastLine) { + lastLine = start + let indent = getIndentation(state, start), current + if (indent > -1 && + indent != (current = /^\s*/.exec(state.doc.slice(start, Math.min(end, start + 100)))![0].length)) + positions.push({pos: start, current, indent}) + } + if (end + 1 > range.to) break + ;({start, end} = state.doc.lineAt(end + 1)) + } + } + if (positions.length > 0) { + let tr = state.transaction + for (let {pos, current, indent} of positions) { + let start = tr.changes.mapPos(pos) + tr = tr.replace(start, start + current, space(indent)) + } + dispatch(tr) + } + return true +} + export const pcBaseKeymap: {[key: string]: Command} = { "ArrowLeft": moveCharLeft, "ArrowRight": moveCharRight, @@ -107,7 +159,8 @@ export const pcBaseKeymap: {[key: string]: Command} = { "Mod-End": selectDocEnd, "Mod-a": selectAll, "Backspace": deleteCharBackward, - "Delete": deleteCharForward + "Delete": deleteCharForward, + "Enter": insertNewlineAndIndent } export const macBaseKeymap: {[key: string]: Command} = { diff --git a/demo/demo.ts b/demo/demo.ts index 526e1582..125efa34 100644 --- a/demo/demo.ts +++ b/demo/demo.ts @@ -3,36 +3,14 @@ import {EditorView} from "../view/src/" import {keymap} from "../keymap/src/keymap" import {history, redo, redoSelection, undo, undoSelection} from "../history/src/history" import {gutter} from "../gutter/src/index" -import {baseKeymap} from "../commands/src/commands" +import {baseKeymap, indentSelection} from "../commands/src/commands" import {legacyMode} from "../legacy-modes/src/index" import {matchBrackets} from "../matchbrackets/src/matchbrackets" import javascript from "../legacy-modes/src/javascript" import {specialChars} from "../special-chars/src/special-chars" import {multipleSelections} from "../multiple-selections/src/multiple-selections" -let mode = legacyMode(javascript({indentUnit: 2}, {})) - -// FIXME these should move to commands and access the indentation -// feature through some kind of generic mechanism that allows plugins -// to advertise that they can do indentation -function crudeInsertNewlineAndIndent({state, dispatch}: EditorView): boolean { - let indentation = (mode as any).indentation(state, state.selection.primary.from) - if (indentation > -1) - dispatch(state.transaction.replaceSelection("\n" + " ".repeat(indentation)).scrollIntoView()) - return true -} -function crudeIndentLine({state, dispatch}: EditorView): boolean { - let cursor = state.selection.primary.head // FIXME doesn't indent multiple lines - let line = state.doc.lineAt(cursor), text = line.slice(0, 100) - let space = /^ */.exec(text)[0].length // FIXME doesn't handle tabs - let indentation = (mode as any).indentation(state, line.start) - if (indentation == -1) indentation = space - let tr = state.transaction.replace(line.start, line.start + space, " ".repeat(indentation)).scrollIntoView() - if (cursor <= line.start + space) - tr = tr.setSelection(EditorSelection.single(line.start + indentation)) - dispatch(tr) - return true -} +let mode = legacyMode({mode: javascript({indentUnit: 2}, {}) as any}) let isMac = /Mac/.test(navigator.platform) let state = EditorState.create({doc: `"use strict"; @@ -40,21 +18,20 @@ const {readFile} = require("fs"); readFile("package.json", "utf8", (err, data) => { console.log(data); -});`, plugins: [ +});`, extensions: [ gutter(), history(), - specialChars({}), + specialChars(), multipleSelections(), mode, - matchBrackets({decorationsPlugin: mode}), + matchBrackets(), keymap({ "Mod-z": undo, "Mod-Shift-z": redo, "Mod-u": view => undoSelection(view) || true, [isMac ? "Mod-Shift-u" : "Alt-u"]: redoSelection, "Ctrl-y": isMac ? undefined : redo, - "Enter": crudeInsertNewlineAndIndent, - "Shift-Tab": crudeIndentLine + "Shift-Tab": indentSelection }), keymap(baseKeymap), ]}) diff --git a/extension/src/extension.ts b/extension/src/extension.ts new file mode 100644 index 00000000..16b2acf3 --- /dev/null +++ b/extension/src/extension.ts @@ -0,0 +1,182 @@ +const none = [] as any + +const enum Kind { BEHAVIOR, MULTI, UNIQUE } + +export class Extension { + // @internal + constructor(/* @internal */ public kind: Kind, + /* @internal */ public id: any, + /* @internal */ public value: any, + /* @internal */ public priority: number = -2) {} + + private setPrio(priority: number): this { + // Crude casting because TypeScript doesn't understand new this.constructor + return new (this.constructor as any)(this.kind, this.id, this.value, priority) as this + } + fallback() { return this.setPrio(-1) } + extend() { return this.setPrio(1) } + override() { return this.setPrio(2) } + + // @internal + flatten(priority: number, target: Extension[] = []) { + if (this.kind == Kind.MULTI) for (let ext of this.value as Extension[]) ext.flatten(this.priority > -2 ? this.priority : priority, target) + else target.push(this.priority > -2 ? this : this.setPrio(priority)) + return target + } + + // Insert this extension in an array of extensions so that it + // appears after any already-present extensions with the same or + // lower priority, but before any extensions with higher priority. + // @internal + collect(array: Extension[]) { + let i = 0 + while (i < array.length && array[i].priority >= this.priority) i++ + array.splice(i, 0, this) + } + + // Define a type of behavior, which is the thing that extensions + // eventually resolve to. Each behavior can have an ordered sequence + // of values associated with it. An `Extension` can be seen as a + // tree of sub-extensions with behaviors as leaves. + static defineBehavior() { + let behavior = (value: Value) => new this(Kind.BEHAVIOR, behavior, value) + return behavior + } + + static unique(instantiate: (specs: Spec[]) => Extension, defaultSpec?: Spec): (spec?: Spec) => Extension { + const type = new UniqueExtensionType(instantiate) + return (spec: Spec | undefined = defaultSpec) => { + if (spec === undefined) throw new RangeError("This extension has no default spec") + return new this(Kind.UNIQUE, type, spec) + } + } + + static all(...extensions: Extension[]) { + return new this(Kind.MULTI, null, extensions) + } + + // Resolve an array of extenders by expanding all extensions until + // only behaviors are left, and then collecting the behaviors into + // arrays of values, preserving priority ordering throughout. + static resolve(extensions: ReadonlyArray): BehaviorStore { + let pending: Extension[] = new this(Kind.MULTI, null, extensions).flatten(0) + // This does a crude topological ordering to resolve behaviors + // top-to-bottom in the dependency ordering. If there are no + // cyclic dependencies, we can always find a behavior in the top + // `pending` array that isn't a dependency of any unresolved + // behavior, and thus find and order all its specs in order to + // resolve them. + for (let resolved: UniqueExtensionType[] = [];;) { + let top = findTopUnique(pending, this) + if (!top) break // Only behaviors left + // Prematurely evaluated a behavior type because of missing + // sub-behavior information -- start over, in the assumption + // that newly gathered information will make the next attempt + // more successful. + if (resolved.indexOf(top) > -1) return this.resolve(extensions) + top.resolve(pending) + resolved.push(top) + } + // Collect the behavior values. + let store = new BehaviorStore + for (let ext of pending) { + if (!(ext instanceof this)) { + // Collect extensions of the wrong type into store.foreign + store.foreign.push(ext) + continue + } + if (store.behaviors.indexOf(ext.id) > -1) continue // Already collected + let values: Extension[] = [] + for (let e of pending) if (e.id == ext.id) e.collect(values) + store.behaviors.push(ext.id) + store.values.push(values.map(v => v.value)) + } + return store + } +} + +class UniqueExtensionType { + knownSubs: UniqueExtensionType[] = [] + + constructor(public instantiate: (...specs: any[]) => Extension) {} + + hasSub(type: UniqueExtensionType): boolean { + for (let known of this.knownSubs) + if (known == type || known.hasSub(type)) return true + return false + } + + resolve(extensions: Extension[]) { + // Replace all instances of this type in extneions with the + // sub-extensions that instantiating produces. + let ours: Extension[] = [] + for (let ext of extensions) if (ext.id == this) ext.collect(ours) + let first = true + for (let i = 0; i < extensions.length; i++) { + let ext = extensions[i] + if (ext.id != this) continue + let sub = first ? this.subs(ours.map(s => s.value), ext.priority) : none + extensions.splice(i, 1, ...sub) + first = false + i += sub.length - 1 + } + } + + subs(specs: any[], priority: number) { + let subs = this.instantiate(specs).flatten(priority) + for (let sub of subs) + if (sub.kind == Kind.UNIQUE && this.knownSubs.indexOf(sub.id) == -1) this.knownSubs.push(sub.id) + return subs + } +} + +// An instance of this is part of EditorState and stores the behaviors +// provided for the state. +export class BehaviorStore { + // @internal + behaviors: any[] = [] + // @internal + values: any[][] = [] + // Any extensions that weren't an instance of the given type when + // resolving. + foreign: Extension[] = [] + + get(behavior: (v: Value) => Extension): Value[] { + let found = this.behaviors.indexOf(behavior) + return found < 0 ? none : this.values[found] + } +} + +// Find the extension type that must be resolved next, meaning it is +// not a (transitive) sub-extension of any other extensions that are +// still in extenders. +function findTopUnique(extensions: Extension[], type: typeof Extension): UniqueExtensionType | null { + let foundUnique = false + for (let ext of extensions) if (ext.kind == Kind.UNIQUE && ext instanceof type) { + foundUnique = true + if (!extensions.some(e => e.kind == Kind.UNIQUE && (e.id as UniqueExtensionType).hasSub(ext.id))) + return ext.id + } + if (foundUnique) throw new RangeError("Sub-extension cycle in unique extensions") + return null +} + +// Utility function for combining behaviors to fill in a config +// object from an array of provided configs. Will, by default, error +// when a field gets two values that aren't ===-equal, but you can +// provide combine functions per field to do something else. +export function combineConfig(configs: ReadonlyArray, + combine: {[key: string]: (first: any, second: any) => any} = {}, + defaults?: Config): Config { + let result: any = {} + for (let config of configs) for (let key of Object.keys(config)) { + let value = (config as any)[key], current = result[key] + if (current === undefined) result[key] = value + else if (current === value || value === undefined) {} // No conflict + else if (Object.hasOwnProperty.call(combine, key)) result[key] = combine[key](current, value) + else throw new Error("Config merge conflict for field " + key) + } + if (defaults) for (let key in defaults) + if (result[key] === undefined) result[key] = (defaults as any)[key] + return result +} diff --git a/extension/test/test-extension.ts b/extension/test/test-extension.ts new file mode 100644 index 00000000..3f1feba3 --- /dev/null +++ b/extension/test/test-extension.ts @@ -0,0 +1,56 @@ +const ist = require("ist") +import {Extension} from "../src/extension" + +function mk(...extensions: Extension[]) { return Extension.resolve(extensions) } + +let num = Extension.defineBehavior() + +describe("EditorState behavior", () => { + it("allows querying of behaviors", () => { + let str = Extension.defineBehavior() + let store = mk(num(10), num(20), str("x"), str("y")) + ist(store.get(num).join(), "10,20") + ist(store.get(str).join(), "x,y") + }) + + it("includes sub-extenders", () => { + let e = (s: string) => Extension.all(num(s.length), num(+s)) + let store = mk(num(5), e("20"), num(40), e("100")) + ist(store.get(num).join(), "5,2,20,40,3,100") + }) + + it("only includes sub-behaviors of unique extensions once", () => { + let e = Extension.unique(ns => num(ns.reduce((a, b) => a + b, 0))) + let store = mk(num(1), e(2), num(4), e(8)) + ist(store.get(num).join(), "1,10,4") + }) + + it("returns an empty array for absent behavior", () => { + ist(JSON.stringify(mk().get(num)), "[]") + }) + + it("sorts extensions by priority", () => { + let str = Extension.defineBehavior() + let store = mk(str("a"), str("b"), str("c").extend(), + str("d").override(), str("e").fallback(), + str("f").extend(), str("g")) + ist(store.get(str).join(), "d,c,f,a,b,g,e") + }) + + it("lets sub-extensions inherit their parent's priority", () => { + let e = (n: number) => num(n) + let store = mk(num(1), e(2).override(), e(4)) + ist(store.get(num).join(), "2,1,4") + }) + + it("uses default specs", () => { + let e = Extension.unique((specs: number[]) => num(specs.reduce((a, b) => a + b)), 10) + let store = mk(e(), e(5)) + ist(store.get(num).join(), "15") + }) + + it("only allows omitting use argument when there's a default", () => { + let e = Extension.unique((specs: number[]) => num(0)) + ist.throws(() => e()) + }) +}) diff --git a/gutter/src/index.ts b/gutter/src/index.ts index 09dccef2..c2b10666 100644 --- a/gutter/src/index.ts +++ b/gutter/src/index.ts @@ -1,5 +1,5 @@ -import {Plugin} from "../../state/src" -import {EditorView} from "../../view/src" +import {combineConfig} from "../../extension/src/extension" +import {EditorView, ViewExtension, DOMEffect} from "../../view/src" // FIXME Think about how the gutter width changing could cause // problems when line wrapping is on by changing a line's height @@ -10,27 +10,28 @@ import {EditorView} from "../../view/src" // FIXME at some point, add support for custom gutter space and // per-line markers -// FIXME seriously slow on Firefox, quite fast on Chrome +// FIXME seriously slow on Firefox when devtools are open + +// FIXME this forces a checkLayout right on init, which is wasteful export interface GutterConfig { fixed?: boolean, formatNumber?: (lineNo: number) => string } -export function gutter(config: GutterConfig = {}) { - return new Plugin({ - view(view: EditorView) { return new GutterView(view, config) } - }) -} +export const gutter = ViewExtension.unique(configs => { + let config = combineConfig(configs) + return ViewExtension.domEffect(view => new GutterView(view, config)) +}, {}) -class GutterView { +class GutterView implements DOMEffect { dom: HTMLElement spaceAbove: number = 0 lines: GutterLine[] = [] lastLine: GutterLine formatNumber: (lineNo: number) => string - constructor(view: EditorView, config: GutterConfig) { + constructor(public view: EditorView, config: GutterConfig) { this.dom = document.createElement("div") this.dom.className = "CodeMirror-gutter" this.dom.setAttribute("aria-hidden", "true") @@ -46,30 +47,30 @@ class GutterView { this.lastLine = new GutterLine(1, 0, 0, 0, this.formatNumber) this.lastLine.dom.style.cssText += "visibility: hidden; pointer-events: none" this.dom.appendChild(this.lastLine.dom) - this.updateDOM(view) + this.update() } - updateDOM(view: EditorView) { + update() { // Create the first number consisting of all 9s that is at least // as big as the line count, and put that in this.lastLine to make // sure the gutter width is stable let last = 9 - while (last < view.state.doc.lines) last = last * 10 + 9 + while (last < this.view.state.doc.lines) last = last * 10 + 9 this.lastLine.update(last, 0, 0, 0, this.formatNumber) // FIXME would be nice to be able to recognize updates that didn't redraw - this.updateGutter(view) + this.updateGutter() } - updateGutter(view: EditorView) { - let spaceAbove = view.heightAtPos(view.viewport.from, true) + updateGutter() { + let spaceAbove = this.view.heightAtPos(this.view.viewport.from, true) if (spaceAbove != this.spaceAbove) { this.spaceAbove = spaceAbove this.dom.style.paddingTop = spaceAbove + "px" } let i = 0, lineNo = -1 - view.viewport.forEachLine(line => { + this.view.viewport.forEachLine(line => { let above = line.textTop, below = line.height - line.textBottom, height = line.height - above - below - if (lineNo < 0) lineNo = view.state.doc.lineAt(line.start).number + if (lineNo < 0) lineNo = this.view.state.doc.lineAt(line.start).number if (i == this.lines.length) { let newLine = new GutterLine(lineNo, height, above, below, this.formatNumber) this.lines.push(newLine) @@ -81,7 +82,7 @@ class GutterView { i++ }) while (this.lines.length > i) this.dom.removeChild(this.lines.pop()!.dom) - this.dom.style.minHeight = view.contentHeight + "px" + this.dom.style.minHeight = this.view.contentHeight + "px" } destroy() { diff --git a/history/src/history.ts b/history/src/history.ts index 2950faeb..f2ec5225 100644 --- a/history/src/history.ts +++ b/history/src/history.ts @@ -1,61 +1,70 @@ -import {EditorState, Transaction, StateField, MetaSlot, Plugin} from "../../state/src" +import {EditorState, Transaction, StateField, MetaSlot, StateExtension} from "../../state/src" +import {combineConfig} from "../../extension/src/extension" import {HistoryState, ItemFilter, PopTarget} from "./core" const historyStateSlot = new MetaSlot("historyState") export const closeHistorySlot = new MetaSlot("historyClose") -const historyField = new StateField({ - init(editorState: EditorState): HistoryState { - return HistoryState.empty - }, - - apply(tr: Transaction, state: HistoryState, editorState: EditorState): HistoryState { - const fromMeta = tr.getMeta(historyStateSlot) - if (fromMeta) return fromMeta - if (tr.getMeta(closeHistorySlot)) state = state.resetTime() - if (!tr.changes.length && !tr.selectionSet) return state - - const {newGroupDelay, minDepth} = editorState.getPluginWithField(historyField).config - if (tr.getMeta(MetaSlot.addToHistory) !== false) - return state.addChanges(tr.changes, tr.changes.length ? tr.invertedChanges() : null, tr.startState.selection, - tr.getMeta(MetaSlot.time)!, tr.getMeta(MetaSlot.userEvent), newGroupDelay, minDepth) - return state.addMapping(tr.changes.desc, minDepth) - }, - - debugName: "historyState" -}) - -export function history({minDepth = 100, newGroupDelay = 500}: {minDepth?: number, newGroupDelay?: number} = {}): Plugin { - return new Plugin({ - state: historyField, - config: {minDepth, newGroupDelay} +function historyField(minDepth: number, newGroupDelay: number) { + return new StateField({ + init(editorState: EditorState): HistoryState { + return HistoryState.empty + }, + + apply(tr: Transaction, state: HistoryState, editorState: EditorState): HistoryState { + const fromMeta = tr.getMeta(historyStateSlot) + if (fromMeta) return fromMeta + if (tr.getMeta(closeHistorySlot)) state = state.resetTime() + if (!tr.changes.length && !tr.selectionSet) return state + + if (tr.getMeta(MetaSlot.addToHistory) !== false) + return state.addChanges(tr.changes, tr.changes.length ? tr.invertedChanges() : null, + tr.startState.selection, tr.getMeta(MetaSlot.time)!, + tr.getMeta(MetaSlot.userEvent), newGroupDelay, minDepth) + return state.addMapping(tr.changes.desc, minDepth) + }, + + name: "historyState" }) } -function historyCmd(target: PopTarget, only: ItemFilter, state: EditorState, dispatch: (tr: Transaction) => void): boolean { - const historyState: HistoryState | undefined = state.getField(historyField) - if (!historyState || !historyState.canPop(target, only)) return false - const {minDepth} = state.getPluginWithField(historyField).config - const {transaction, state: newState} = historyState.pop(target, only, state.transaction, minDepth) - dispatch(transaction.setMeta(historyStateSlot, newState)) - return true -} +export interface HistoryConfig {minDepth?: number, newGroupDelay?: number} -export function undo({state, dispatch}: {state: EditorState, dispatch: (tr: Transaction) => void}): boolean { - return historyCmd(PopTarget.Done, ItemFilter.OnlyChanges, state, dispatch) +class HistoryContext { + constructor(public field: StateField, public config: HistoryConfig) {} } -export function redo({state, dispatch}: {state: EditorState, dispatch: (tr: Transaction) => void}): boolean { - return historyCmd(PopTarget.Undone, ItemFilter.OnlyChanges, state, dispatch) -} +const historyBehavior = StateExtension.defineBehavior() -export function undoSelection({state, dispatch}: {state: EditorState, dispatch: (tr: Transaction) => void}): boolean { - return historyCmd(PopTarget.Done, ItemFilter.Any, state, dispatch) +export const history = StateExtension.unique(configs => { + let config = combineConfig(configs, {minDepth: Math.max}, { + minDepth: 100, + newGroupDelay: 500 + }) + let field = historyField(config.minDepth!, config.newGroupDelay!) + return StateExtension.all( + StateExtension.stateField(field), + historyBehavior(new HistoryContext(field, config)) + ) +}, {}) + +function cmd(target: PopTarget, only: ItemFilter) { + return function({state, dispatch}: {state: EditorState, dispatch: (tr: Transaction) => void}) { + let hist = state.behavior.get(historyBehavior) + if (!hist.length) return false + let {field, config} = hist[0] + let historyState = state.getField(field) + if (!historyState.canPop(target, only)) return false + const {transaction, state: newState} = historyState.pop(target, only, state.transaction, config.minDepth!) + dispatch(transaction.setMeta(historyStateSlot, newState)) + return true + } } -export function redoSelection({state, dispatch}: {state: EditorState, dispatch: (tr: Transaction) => void}): boolean { - return historyCmd(PopTarget.Undone, ItemFilter.Any, state, dispatch) -} +export const undo = cmd(PopTarget.Done, ItemFilter.OnlyChanges) +export const redo = cmd(PopTarget.Undone, ItemFilter.OnlyChanges) +export const undoSelection = cmd(PopTarget.Done, ItemFilter.Any) +export const redoSelection = cmd(PopTarget.Undone, ItemFilter.Any) // Set a flag on the given transaction that will prevent further steps // from being appended to an existing history event (so that they @@ -64,26 +73,20 @@ export function closeHistory(tr: Transaction): Transaction { return tr.setMeta(closeHistorySlot, true) } -// The amount of undoable change events available in a given state. -export function undoDepth(state: EditorState): number { - let hist = state.getField(historyField) - return hist ? hist.eventCount(PopTarget.Done, ItemFilter.OnlyChanges) : 0 +function depth(target: PopTarget, only: ItemFilter) { + return function(state: EditorState): number { + let hist = state.behavior.get(historyBehavior) + if (hist.length == 0) return 0 + let {field} = hist[0] + return state.getField(field).eventCount(target, only) + } } +// The amount of undoable change events available in a given state. +export const undoDepth = depth(PopTarget.Done, ItemFilter.OnlyChanges) // The amount of redoable change events available in a given state. -export function redoDepth(state: EditorState): number { - let hist = state.getField(historyField) - return hist ? hist.eventCount(PopTarget.Undone, ItemFilter.OnlyChanges) : 0 -} - +export const redoDepth = depth(PopTarget.Undone, ItemFilter.OnlyChanges) // The amount of undoable events available in a given state. -export function undoSelectionDepth(state: EditorState): number { - let hist = state.getField(historyField) - return hist ? hist.eventCount(PopTarget.Done, ItemFilter.Any) : 0 -} - +export const redoSelectionDepth = depth(PopTarget.Done, ItemFilter.Any) // The amount of redoable events available in a given state. -export function redoSelectionDepth(state: EditorState): number { - let hist = state.getField(historyField) - return hist ? hist.eventCount(PopTarget.Undone, ItemFilter.Any) : 0 -} +export const undoSelectionDepth = depth(PopTarget.Undone, ItemFilter.Any) diff --git a/history/test/test-history.ts b/history/test/test-history.ts index 454a76f8..dbf212f5 100644 --- a/history/test/test-history.ts +++ b/history/test/test-history.ts @@ -1,9 +1,13 @@ const ist = require("ist") -import {EditorState, EditorSelection, SelectionRange, Transaction, MetaSlot} from "../../state/src" -import {closeHistory, history, redo, redoDepth, redoSelection, undo, undoDepth, undoSelection} from "../src/history" +import {EditorState, EditorSelection, SelectionRange, Transaction, MetaSlot, StateExtension} from "../../state/src" +import {closeHistory, history, redo, redoDepth, redoSelection, undo, undoDepth, + undoSelection} from "../src/history" -const mkState = (config?: any, doc?: string) => EditorState.create({plugins: [history(config)], doc, multipleSelections: true}) +const mkState = (config?: any, doc?: string) => EditorState.create({ + extensions: [history(config), StateExtension.allowMultipleSelections(true)], + doc +}) const type = (state: EditorState, text: string, at = state.doc.length) => state.transaction.replace(at, at, text).apply() const timedType = (state: EditorState, text: string, atTime: number) => Transaction.start(state, atTime).replace(state.doc.length, state.doc.length, text).apply() diff --git a/keymap/src/keymap.ts b/keymap/src/keymap.ts index 657eb5a7..35156a0a 100644 --- a/keymap/src/keymap.ts +++ b/keymap/src/keymap.ts @@ -1,9 +1,8 @@ import {base, keyName} from "w3c-keyname" - -import {Plugin} from "../../state/src" -import {EditorView} from "../../view/src" +import {EditorView, ViewExtension} from "../../view/src" export type Command = (view: EditorView) => boolean +export type Keymap = {[key: string]: Command} const mac = typeof navigator != "undefined" ? /Mac/.test(navigator.platform) : false @@ -28,7 +27,7 @@ function normalizeKeyName(name: string): string { return result } -function normalize(map: {[key: string]: Command}): {[key: string]: Command} { +function normalize(map: Keymap): Keymap { const copy = Object.create(null) for (const prop in map) copy[normalizeKeyName(prop)] = map[prop] return copy @@ -42,19 +41,15 @@ function modifiers(name: string, event: KeyboardEvent, shift: boolean) { return name } -// :: (Object) → Plugin -// Create a keymap plugin for the given set of bindings. +// Behavior for defining keymaps // -// Bindings should map key names to [command](#commands)-style -// functions, which will be called with `(EditorState, dispatch, -// EditorView)` arguments, and should return true when they've handled -// the key. Note that the view argument isn't part of the command -// protocol, but can be used as an escape hatch if a binding needs to -// directly interact with the UI. +// Specs are objects that map key names to command-style functions, +// which will be called with an editor view and should return true +// when they've handled the key. // -// Key names may be strings like `"Shift-Ctrl-Enter"`—a key -// identifier prefixed with zero or more modifiers. Key identifiers -// are based on the strings that can appear in +// Key names may be strings like `"Shift-Ctrl-Enter"`—a key identifier +// prefixed with zero or more modifiers. Key identifiers are based on +// the strings that can appear in // [`KeyEvent.key`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key). // Use lowercase letters to refer to letter keys (or uppercase letters // if you want shift to be held). You may use `"Space"` as an alias @@ -62,31 +57,19 @@ function modifiers(name: string, event: KeyboardEvent, shift: boolean) { // // Modifiers can be given in any order. `Shift-` (or `s-`), `Alt-` (or // `a-`), `Ctrl-` (or `c-` or `Control-`) and `Cmd-` (or `m-` or -// `Meta-`) are recognized. For characters that are created by holding -// shift, the `Shift-` prefix is implied, and should not be added -// explicitly. +// `Meta-`) are recognized. // // You can use `Mod-` as a shorthand for `Cmd-` on Mac and `Ctrl-` on // other platforms. // -// You can add multiple keymap plugins to an editor. The order in -// which they appear determines their precedence (the ones early in -// the array get to dispatch first). -export function keymap(bindings: {[key: string]: Command}): Plugin { - let keydown = keydownHandler(bindings) - return new Plugin({ - view() { - return {handleDOMEvents: {keydown}} - } - }) -} +// You can add multiple keymap behaviors to an editor. Their +// priorities determine their precedence (the ones specified early or +// with high priority get to dispatch first). +export const keymap = (map: Keymap) => ViewExtension.handleDOMEvents({ + keydown: keydownHandler(normalize(map)) +}) -// :: (Object) → (view: EditorView, event: dom.Event) → bool -// Given a set of bindings (using the same format as -// [`keymap`](#keymap.keymap), return a [keydown -// handler](#view.EditorProps.handleKeyDown) handles them. -export function keydownHandler(bindings: {[key: string]: Command}): (view: EditorView, event: KeyboardEvent) => boolean { - const map = normalize(bindings) +function keydownHandler(map: Keymap): (view: EditorView, event: KeyboardEvent) => boolean { return function(view, event) { const name = keyName(event), isChar = name.length == 1 && name != " " const direct = map[modifiers(name, event, !isChar)] diff --git a/keymap/test/test-keymap.ts b/keymap/test/test-keymap.ts index 8bb68a74..01524c96 100644 --- a/keymap/test/test-keymap.ts +++ b/keymap/test/test-keymap.ts @@ -1,11 +1,16 @@ -import {keymap} from "../src/keymap" +import {keymap, Keymap} from "../src/keymap" +import {ViewExtension} from "../../view/src" const ist = require("ist") const fakeView = {state: {}, dispatch: () => {}} -function dispatch(map: any, key: string, mods?: any) { +function mk(map: Keymap) { + return ViewExtension.resolve([keymap(map)]).get(ViewExtension.handleDOMEvents)[0] +} + +function dispatch(handlers: any, key: string, mods?: any) { let event: Partial = Object.assign({}, mods, {key}) - map.view().handleDOMEvents.keydown(fakeView, event) + handlers.keydown(fakeView, event) } function counter() { @@ -16,14 +21,14 @@ function counter() { describe("keymap", () => { it("calls the correct handler", () => { let a = counter(), b = counter() - dispatch(keymap({KeyA: a, KeyB: b}), "KeyA") + dispatch(mk({KeyA: a, KeyB: b}), "KeyA") ist(a.count, 1) ist(b.count, 0) }) it("distinguishes between modifiers", () => { let s = counter(), c_s = counter(), s_c_s = counter(), a_s = counter() - let map = keymap({"Space": s, "Control-Space": c_s, "s-c-Space": s_c_s, "alt-Space": a_s}) + let map = mk({"Space": s, "Control-Space": c_s, "s-c-Space": s_c_s, "alt-Space": a_s}) dispatch(map, " ", {ctrlKey: true}) dispatch(map, " ", {ctrlKey: true, shiftKey: true}) ist(s.count, 0) @@ -34,7 +39,7 @@ describe("keymap", () => { it("passes the state, dispatch, and view", () => { let called = false - dispatch(keymap({X: (view) => { + dispatch(mk({X: (view) => { ist(view, fakeView) return called = true }}), "X") @@ -43,15 +48,15 @@ describe("keymap", () => { it("tries both shifted key and base with shift modifier", () => { let percent = counter(), shift5 = counter() - dispatch(keymap({"%": percent}), "%", {shiftKey: true, keyCode: 53}) + dispatch(mk({"%": percent}), "%", {shiftKey: true, keyCode: 53}) ist(percent.count, 1) - dispatch(keymap({"Shift-5": shift5}), "%", {shiftKey: true, keyCode: 53}) + dispatch(mk({"Shift-5": shift5}), "%", {shiftKey: true, keyCode: 53}) ist(shift5.count, 1) }) it("tries keyCode when modifier active", () => { let count = counter() - dispatch(keymap({"Shift-Alt-3": count}), "×", {shiftKey: true, altKey: true, keyCode: 51}) + dispatch(mk({"Shift-Alt-3": count}), "×", {shiftKey: true, altKey: true, keyCode: 51}) ist(count.count, 1) }) }) diff --git a/legacy-modes/src/index.ts b/legacy-modes/src/index.ts index 6b7a16da..09c64f7b 100644 --- a/legacy-modes/src/index.ts +++ b/legacy-modes/src/index.ts @@ -1,6 +1,6 @@ -import {EditorView, ViewUpdate} from "../../view/src" +import {EditorView, ViewUpdate, ViewExtension} from "../../view/src" import {Range} from "../../rangeset/src/rangeset" -import {EditorState, Plugin, StateField, Transaction} from "../../state/src" +import {EditorState, StateExtension, StateField, Transaction} from "../../state/src" import {Decoration} from "../../view/src/decoration" import {StringStreamCursor} from "./stringstreamcursor" @@ -144,48 +144,47 @@ class StateCache { type Config = { sleepTime?: number, - maxWorkTime?: number + maxWorkTime?: number, + mode: Mode } -export function legacyMode(mode: Mode, config: Config = {}) { - const {sleepTime = 100, maxWorkTime = 100} = config - const field = new StateField>({ +export const legacyMode = (config: Config) => { + let field = new StateField>({ init(state: EditorState) { return new StateCache([], 0, null) }, apply(tr, cache) { return cache.apply(tr) }, - debugName: "mode" + name: "mode" }) + return StateExtension.all( + StateExtension.stateField(field), + ViewExtension.decorations(decoSpec(field, config)), + StateExtension.indentation((state: EditorState, pos: number): number => { + if (!config.mode.indent) return -1 + let modeState = state.getField(field).getState(state, pos, config.mode) + let line = state.doc.lineAt(pos) + return config.mode.indent(modeState, line.slice(0, Math.min(line.length, 100)).match(/^\s*(.*)/)![1]) + }) + // FIXME add a token-retrieving behavior + ) +} - let plugin = new Plugin({ - state: field, - view(v: EditorView) { - let decorations = Decoration.none, from = -1, to = -1 - function update(v: EditorView, force: boolean) { - let vp = v.viewport - if (force || vp.from < from || vp.to > to) { - ;({from, to} = vp) - const stateCache = v.state.getField(field)! - decorations = Decoration.set(stateCache.getDecorations(v.state, from, to, mode)) - stateCache.advanceFrontier(v.state, from, mode, sleepTime, maxWorkTime).then(() => { - update(v, true) - v.updateState([], v.state) - }, () => {}) - } - } - return { - get decorations() { return decorations }, - update: (v: EditorView, u: ViewUpdate) => update(v, u.transactions.some(tr => tr.docChanged)) - } +function decoSpec(field: StateField>, config: Config) { + const {sleepTime = 100, maxWorkTime = 100, mode} = config + let decorations = Decoration.none, from = -1, to = -1 + function update(v: EditorView, force: boolean) { + let vp = v.viewport + if (force || vp.from < from || vp.to > to) { + ;({from, to} = vp) + const stateCache = v.state.getField(field)! + decorations = Decoration.set(stateCache.getDecorations(v.state, from, to, mode)) + stateCache.advanceFrontier(v.state, from, mode, sleepTime, maxWorkTime).then(() => { + update(v, true) + v.updateState([], v.state) // FIXME maybe add a specific EditorView method for this + }, () => {}) } - }) - - // FIXME Short-term hack—it'd be nice to have a better mechanism for this, - // not sure yet what it'd look like - ;(plugin as any).indentation = function(state: EditorState, pos: number): number { - if (!mode.indent) return -1 - let modeState = state.getField(field)!.getState(state, pos, mode) - let line = state.doc.lineAt(pos) - return mode.indent(modeState, line.slice(0, Math.min(line.length, 100)).match(/^\s*(.*)/)![1]) + return decorations + } + return { + create(view: EditorView) { return update(view, false) }, + update(view: EditorView, {transactions}: ViewUpdate) { return update(view, transactions.some(tr => tr.docChanged)) } } - - return plugin } diff --git a/legacy-modes/test/test-legacymode.ts b/legacy-modes/test/test-legacymode.ts index 1fc5134e..9fde1eb0 100644 --- a/legacy-modes/test/test-legacymode.ts +++ b/legacy-modes/test/test-legacymode.ts @@ -1,4 +1,5 @@ import {EditorState, Transaction} from "../../state/src" +import {ViewExtension} from "../../view/src" import {RangeDecoration} from "../../view/src/decoration" import {Range} from "../../rangeset/src/rangeset" @@ -22,21 +23,24 @@ function getModeTest(doc: string, onDecorationUpdate = () => {}) { return String(++state.pos) } } - const plugin = legacyMode(mode, {sleepTime: 0}) + const extension = legacyMode({mode, sleepTime: 0}) const view: {state: EditorState, viewport?: Viewport, updateState: () => void} = { - state: EditorState.create({doc, plugins: [plugin]}), + state: EditorState.create({doc, extensions: [extension]}), + viewport: {from: 0, to: 0}, updateState: onDecorationUpdate } - const viewPlugin = plugin.view(view) + // FIXME this is terrible + const plugin = (ViewExtension.resolve(view.state.behavior.foreign) as any).values[0][0] + let decorations = plugin.create(view) return { calls, getDecorations(vp: Viewport) { view.viewport = vp - viewPlugin.update(view, {transactions: []}) - const decorations: Range[] = [] - viewPlugin.decorations.collect(decorations) - return decorations + decorations = plugin.update(view, {transactions: []}, decorations) + const result: Range[] = [] + decorations.collect(result, 0) + return result }, get transaction() { return view.state.transaction @@ -44,7 +48,7 @@ function getModeTest(doc: string, onDecorationUpdate = () => {}) { apply(transaction: Transaction, {from, to}: Viewport) { view.state = transaction.apply() view.viewport = {from, to} - viewPlugin.update(view, {transactions: [transaction]}) + decorations = plugin.update(view, {transactions: [transaction]}, decorations) } } } diff --git a/matchbrackets/src/matchbrackets.ts b/matchbrackets/src/matchbrackets.ts index ea0486b6..b904a2b9 100644 --- a/matchbrackets/src/matchbrackets.ts +++ b/matchbrackets/src/matchbrackets.ts @@ -1,9 +1,17 @@ import {Text} from "../../doc/src" -import {EditorState, Plugin} from "../../state/src" -import {EditorView, ViewUpdate} from "../../view/src/" +import {EditorState} from "../../state/src" +import {combineConfig} from "../../extension/src/extension" +import {ViewExtension} from "../../view/src/" import {Decoration, DecorationSet, RangeDecoration} from "../../view/src/decoration" -const matching: {[key: string]: string | undefined} = {"(": ")>", ")": "(<", "[": "]>", "]": "[<", "{": "}>", "}": "{<"} +const matching: {[key: string]: string | undefined} = { + "(": ")>", + ")": "(<", + "[": "]>", + "]": "[<", + "{": "}>", + "}": "{<" +} export type Config = { afterCursor?: boolean, @@ -22,7 +30,10 @@ function getStyle(decorations: DecorationSet | undefined, at: number): string | return (decoration.value as RangeDecoration).spec.class } -export function findMatchingBracket(doc: Text, decorations: DecorationSet | undefined, where: number, config: Config = {}): {from: number, to: number | null, forward: boolean, match: boolean} | null { +export function findMatchingBracket( + doc: Text, decorations: DecorationSet | undefined, + where: number, config: Config = {} +) : {from: number, to: number | null, forward: boolean, match: boolean} | null { let pos = where - 1 // A cursor is defined as between two characters, but in in vim command mode // (i.e. not insert mode), the cursor is visually represented as a @@ -48,7 +59,8 @@ export function findMatchingBracket(doc: Text, decorations: DecorationSet | unde // // Returns false when no bracket was found, null when it reached // maxScanDistance and gave up -export function scanForBracket(doc: Text, decorations: DecorationSet | undefined, where: number, dir: -1 | 1, style: string | null, config: Config) { +export function scanForBracket(doc: Text, decorations: DecorationSet | undefined, + where: number, dir: -1 | 1, style: string | null, config: Config) { const maxScanDistance = config.maxScanDistance || 10000 const re = config.bracketRegex || /[(){}[\]]/ const stack = [] @@ -85,20 +97,13 @@ function doMatchBrackets(state: EditorState, referenceDecorations: DecorationSet return Decoration.set(decorations) } -export function matchBrackets(config: Config = {}) { - return new Plugin({ - view(v: EditorView) { - const idx = config.decorationsPlugin && v.state.plugins.filter(p => p.view).indexOf(config.decorationsPlugin) - let decorations = Decoration.none - return { - get decorations() { return decorations }, - update(v: EditorView, update: ViewUpdate) { - if (!update.transactions.length) return - // FIXME cast is muffling a justified TypeScript error - const refDecos = idx == undefined ? undefined : (v as any).pluginViews[idx].decorations - decorations = doMatchBrackets(v.state, refDecos, config) - } - } +export const matchBrackets = ViewExtension.unique((configs: Config[]) => { + let config = combineConfig(configs) + return ViewExtension.decorations({ + create(view) { return Decoration.none }, + update({state}, {transactions}, deco) { + // FIXME make this use a tokenizer behavior exported by the highlighter + return transactions.length ? doMatchBrackets(state, undefined, config) : deco } }) -} +}, {}) diff --git a/multiple-selections/src/multiple-selections.ts b/multiple-selections/src/multiple-selections.ts index 8a0c84ae..07fc6020 100644 --- a/multiple-selections/src/multiple-selections.ts +++ b/multiple-selections/src/multiple-selections.ts @@ -1,12 +1,23 @@ -import {Plugin, EditorState} from "../../state/src" -import {EditorView, ViewUpdate, DecorationSet, Decoration, WidgetType, RangeDecorationSpec} from "../../view/src" +import {EditorState, StateExtension} from "../../state/src" +import {ViewExtension, DecorationSet, Decoration, WidgetType, RangeDecorationSpec} from "../../view/src" -export function multipleSelections() { - return new Plugin({ - multipleSelections: true, - view: (view: EditorView) => new MultipleSelectionView(view) - }) -} +export interface Config {} + +export const multipleSelections = StateExtension.unique((configs: Config[]) => { + let rangeConfig = {class: "CodeMirror-secondary-selection"} // FIXME configurable? + + return StateExtension.all( + StateExtension.allowMultipleSelections(true), + ViewExtension.decorations({ + create(view) { return decorateSelections(view.state, rangeConfig) }, + update(_view, {oldState, state}, deco) { + return oldState.doc == state.doc && oldState.selection.eq(state.selection) + ? deco : decorateSelections(state, rangeConfig) + }, + map: false + }) + ) +}, {}) class CursorWidget extends WidgetType { toDOM() { @@ -16,32 +27,14 @@ class CursorWidget extends WidgetType { } } -class MultipleSelectionView { - decorations: DecorationSet = Decoration.none - rangeConfig: RangeDecorationSpec - - constructor(view: EditorView) { - this.updateInner(view.state) - this.rangeConfig = {class: "CodeMirror-secondary-selection"} // FIXME configurable? - } - - update(view: EditorView, update: ViewUpdate) { - if (update.oldState.doc != update.state.doc || !update.oldState.selection.eq(update.state.selection)) - this.updateInner(view.state) - } - - updateInner(state: EditorState) { - let {ranges, primaryIndex} = state.selection - if (ranges.length == 0) { - this.decorations = Decoration.none - return - } - let deco = [] - for (let i = 0; i < ranges.length; i++) if (i != primaryIndex) { - let range = ranges[i] - deco.push(range.empty ? Decoration.widget(range.from, {widget: new CursorWidget(null)}) - : Decoration.range(ranges[i].from, ranges[i].to, this.rangeConfig)) - } - this.decorations = Decoration.set(deco) +function decorateSelections(state: EditorState, rangeConfig: RangeDecorationSpec): DecorationSet { + let {ranges, primaryIndex} = state.selection + if (ranges.length == 1) return Decoration.none + let deco = [] + for (let i = 0; i < ranges.length; i++) if (i != primaryIndex) { + let range = ranges[i] + deco.push(range.empty ? Decoration.widget(range.from, {widget: new CursorWidget(null)}) + : Decoration.range(ranges[i].from, ranges[i].to, rangeConfig)) } + return Decoration.set(deco) } diff --git a/package.json b/package.json index ce00aa47..8b5bce29 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Text/code editor component", "main": "index.js", "scripts": { - "test": "mocha -r ts-node/register/transpile-only doc/test/test-*.ts state/test/test-*.ts history/test/test-*.ts rangeset/test/test-rangeset.ts keymap/test/test-*.ts legacy-modes/test/test-*.ts view/test/test-heightmap.ts", + "test": "mocha -r ts-node/register/transpile-only doc/test/test-*.ts state/test/test-*.ts history/test/test-*.ts rangeset/test/test-rangeset.ts keymap/test/test-*.ts legacy-modes/test/test-*.ts extension/test/test-*.ts view/test/test-heightmap.ts", "watch-demo": "rollup -w -c rollup.demo.config.js", "watch-view-tests": "rollup -w -c rollup.view-tests.config.js" }, diff --git a/special-chars/src/special-chars.ts b/special-chars/src/special-chars.ts index 445c1d63..d4c816f2 100644 --- a/special-chars/src/special-chars.ts +++ b/special-chars/src/special-chars.ts @@ -1,5 +1,6 @@ -import {Decoration, DecoratedRange, DecorationSet, WidgetType, EditorView, ViewUpdate} from "../../view/src" -import {ChangeSet, ChangedRange, Plugin} from "../../state/src" +import {Decoration, DecoratedRange, DecorationSet, WidgetType, EditorView, ViewExtension} from "../../view/src" +import {ChangeSet, ChangedRange, Transaction} from "../../state/src" +import {combineConfig} from "../../extension/src/extension" import {countColumn} from "../../doc/src" export interface SpecialCharOptions { @@ -8,13 +9,15 @@ export interface SpecialCharOptions { addSpecialChars?: RegExp } -export function specialChars(options: SpecialCharOptions = {}): Plugin { - return new Plugin({ - view(view: EditorView) { - return new SpecialCharHighlighter(view, options) - } - }) -} + +export const specialChars = ViewExtension.unique((configs: SpecialCharOptions[]) => { + // FIXME make configurations compose properly + let config = combineConfig(configs) + return ViewExtension.state({ + create(view) { return new SpecialCharHighlighter(view, config) }, + update(view, update, self) { self.update(update.transactions); return self } + }, [ViewExtension.decorationSlot(highlighter => highlighter.decorations)]) +}, {}) const JOIN_GAP = 10 @@ -34,7 +37,7 @@ class SpecialCharHighlighter { this.specials = new RegExp("\t|" + this.specials.source, "gu") } - update(_view: EditorView, {transactions}: ViewUpdate) { + update(transactions: ReadonlyArray) { let allChanges = transactions.reduce((ch, tr) => ch.appendSet(tr.changes), ChangeSet.empty) if (allChanges.length) { this.decorations = this.decorations.map(allChanges) @@ -104,7 +107,6 @@ class SpecialCharHighlighter { } } -// FIXME configurable const SPECIALS = /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200f\u2028\u2029\ufeff]/gu const NAMES: {[key: number]: string} = { diff --git a/state/src/index.ts b/state/src/index.ts index 51902e2e..04ddda44 100644 --- a/state/src/index.ts +++ b/state/src/index.ts @@ -1,5 +1,4 @@ -export {EditorStateConfig, EditorState} from "./state" +export {EditorStateConfig, EditorState, StateField, StateExtension} from "./state" export {EditorSelection, SelectionRange} from "./selection" export {Change, ChangeDesc, ChangeSet, Mapping, ChangedRange} from "./change" export {Transaction, MetaSlot} from "./transaction" -export {StateField, Plugin, PluginSpec} from "./plugin" diff --git a/state/src/plugin.ts b/state/src/plugin.ts deleted file mode 100644 index abded4ae..00000000 --- a/state/src/plugin.ts +++ /dev/null @@ -1,47 +0,0 @@ -import {EditorState} from "./state" -import {Transaction} from "./transaction" - -const fieldNames = Object.create(null) - -export class StateField { - /** @internal */ - readonly key: string - readonly init: (state: EditorState) => T - readonly apply: (tr: Transaction, value: T, newState: EditorState) => T - - constructor({init, apply, debugName = "field"}: { - init: (state: EditorState) => T, - apply: (tr: Transaction, value: T, newState: EditorState) => T, - debugName?: string - }) { - this.init = init - this.apply = apply - this.key = unique("$" + debugName, fieldNames) - } -} - -export interface PluginSpec { - state?: StateField - config?: any - view?: any - multipleSelections?: boolean -} - -export class Plugin { - readonly config: any - readonly stateField: StateField | null - readonly view: (editorView: any) => any - - constructor(readonly spec: PluginSpec) { - this.config = spec.config - this.stateField = spec.state || null - this.view = spec.view - } -} - -export function unique(prefix: string, names: {[key: string]: string}): string { - for (let i = 0;; i++) { - let name = prefix + (i ? "_" + i : "") - if (!(name in names)) return names[name] = name - } -} diff --git a/state/src/state.ts b/state/src/state.ts index 33b1f7ff..8718343e 100644 --- a/state/src/state.ts +++ b/state/src/state.ts @@ -1,67 +1,64 @@ import {joinLines, splitLines, Text} from "../../doc/src" import {EditorSelection} from "./selection" -import {Plugin, StateField} from "./plugin" import {Transaction, MetaSlot} from "./transaction" +import {unique} from "./unique" +import {Extension, BehaviorStore} from "../../extension/src/extension" + +export class StateExtension extends Extension { + static stateField = StateExtension.defineBehavior>() + static allowMultipleSelections = StateExtension.defineBehavior() + static indentation = StateExtension.defineBehavior<(state: EditorState, pos: number) => number>() +} class Configuration { constructor( - readonly plugins: ReadonlyArray, + readonly behavior: BehaviorStore, readonly fields: ReadonlyArray>, readonly multipleSelections: boolean, readonly tabSize: number, readonly lineSeparator: string | null) {} static create(config: EditorStateConfig): Configuration { - let plugins = config.plugins || [], fields = [], multiple = !!config.multipleSelections - for (let plugin of plugins) { - if (plugin.spec.multipleSelections) multiple = true - let field = plugin.stateField - if (!field) continue - if (fields.indexOf(field) > -1) - throw new Error(`A state field (${field.key}) can only be added to a state once`) - fields.push(field) - } - return new Configuration(plugins, fields, multiple, config.tabSize || 4, config.lineSeparator || null) + let behavior = StateExtension.resolve(config.extensions || []) + return new Configuration( + behavior, + behavior.get(StateExtension.stateField), + behavior.get(StateExtension.allowMultipleSelections).some(x => x), + config.tabSize || 4, + config.lineSeparator || null) } updateTabSize(tabSize: number) { - return new Configuration(this.plugins, this.fields, this.multipleSelections, tabSize, this.lineSeparator) + return new Configuration(this.behavior, this.fields, this.multipleSelections, tabSize, this.lineSeparator) } updateLineSeparator(lineSep: string | null) { - return new Configuration(this.plugins, this.fields, this.multipleSelections, this.tabSize, lineSep) + return new Configuration(this.behavior, this.fields, this.multipleSelections, this.tabSize, lineSep) } } export interface EditorStateConfig { doc?: string | Text selection?: EditorSelection - plugins?: ReadonlyArray + extensions?: ReadonlyArray tabSize?: number lineSeparator?: string | null - multipleSelections?: boolean } export class EditorState { /** @internal */ - constructor(private readonly config: Configuration, + constructor(/* @internal */ readonly config: Configuration, + private readonly fields: ReadonlyArray, readonly doc: Text, - readonly selection: EditorSelection = EditorSelection.default) { + readonly selection: EditorSelection) { for (let range of selection.ranges) if (range.to > doc.length) throw new RangeError("Selection points outside of document") } - getField(field: StateField): T | undefined { - return (this as any)[field.key] - } - - get plugins(): ReadonlyArray { return this.config.plugins } - - getPluginWithField(field: StateField): Plugin { - for (const plugin of this.config.plugins) { - if (plugin.stateField == field) return plugin - } - throw new Error("Plugin for field not configured") + getField(field: StateField): T { + let index = this.config.fields.indexOf(field) + if (index < 0) throw new RangeError("Field " + field.name + " is not present in this state") + return this.fields[index] } /** @internal */ @@ -71,9 +68,10 @@ export class EditorState { if (tabSize !== undefined) $conf = $conf.updateTabSize(tabSize) // FIXME changing the line separator might involve rearranging line endings (?) if (lineSep !== undefined) $conf = $conf.updateLineSeparator(lineSep) - let newState = new EditorState($conf, tr.doc, tr.selection) - for (let field of $conf.fields) - (newState as any)[field.key] = field.apply(tr, (this as any)[field.key], newState) + let fields: any[] = [] + let newState = new EditorState($conf, fields, tr.doc, tr.selection) + for (let i = 0; i < this.fields.length; i++) + fields[i] = $conf.fields[i].apply(tr, this.fields[i], newState) return newState } @@ -88,6 +86,8 @@ export class EditorState { joinLines(text: ReadonlyArray): string { return joinLines(text, this.config.lineSeparator || undefined) } splitLines(text: string): string[] { return splitLines(text, this.config.lineSeparator || undefined) } + get behavior() { return this.config.behavior } + // FIXME plugin state serialization toJSON(): any { @@ -106,7 +106,7 @@ export class EditorState { return EditorState.create({ doc: json.doc, selection: EditorSelection.fromJSON(json.selection), - plugins: config.plugins, + extensions: config.extensions, tabSize: config.tabSize, lineSeparator: config.lineSeparator }) @@ -114,11 +114,31 @@ export class EditorState { static create(config: EditorStateConfig = {}): EditorState { let $config = Configuration.create(config) - let doc = config.doc instanceof Text ? config.doc : Text.of(config.doc || "", config.lineSeparator || undefined) + let doc = config.doc instanceof Text ? config.doc + : Text.of(config.doc || "", config.lineSeparator || undefined) let selection = config.selection || EditorSelection.default if (!$config.multipleSelections) selection = selection.asSingle() - let state = new EditorState($config, doc, selection) - for (let field of $config.fields) (state as any)[field.key] = field.init(state) + let fields: any[] = [] + let state = new EditorState($config, fields, doc, selection) + for (let field of $config.fields) fields.push(field.init(state)) return state } } + +export class StateField { + readonly init: (state: EditorState) => T + readonly apply: (tr: Transaction, value: T, newState: EditorState) => T + readonly name: string + + constructor({init, apply, name = "stateField"}: { + init: (state: EditorState) => T, + apply: (tr: Transaction, value: T, newState: EditorState) => T, + name?: string + }) { + this.init = init + this.apply = apply + this.name = unique(name, fieldNames) + } +} + +const fieldNames = Object.create(null) diff --git a/state/src/transaction.ts b/state/src/transaction.ts index 3da3efc5..829e0d92 100644 --- a/state/src/transaction.ts +++ b/state/src/transaction.ts @@ -1,8 +1,8 @@ import {Text} from "../../doc/src" import {EditorState} from "./state" import {EditorSelection, SelectionRange} from "./selection" -import {unique} from "./plugin" import {Change, ChangeSet} from "./change" +import {unique} from "./unique" const empty: ReadonlyArray = [] diff --git a/state/src/unique.ts b/state/src/unique.ts new file mode 100644 index 00000000..22cf2ec9 --- /dev/null +++ b/state/src/unique.ts @@ -0,0 +1,6 @@ +export function unique(prefix: string, names: {[key: string]: string}): string { + for (let i = 0;; i++) { + let name = prefix + (i ? "_" + i : "") + if (!(name in names)) return names[name] = name + } +} diff --git a/state/test/test-state.ts b/state/test/test-state.ts index cd5969b8..cb5c69d0 100644 --- a/state/test/test-state.ts +++ b/state/test/test-state.ts @@ -1,5 +1,5 @@ const ist = require("ist") -import {EditorState, Change, EditorSelection, SelectionRange, MetaSlot} from "../src" +import {EditorState, Change, EditorSelection, SelectionRange, MetaSlot, StateExtension} from "../src" describe("EditorState", () => { it("holds doc and selection properties", () => { @@ -17,7 +17,7 @@ describe("EditorState", () => { it("maps selection through changes", () => { let state = EditorState.create({doc: "abcdefgh", - multipleSelections: true, + extensions: [StateExtension.allowMultipleSelections(true)], selection: EditorSelection.create([0, 4, 8].map(n => new SelectionRange(n)))}) let newState = state.transaction.replaceSelection("Q").apply() ist(newState.doc.toString(), "QabcdQefghQ") diff --git a/view/src/docview.ts b/view/src/docview.ts index 973d0a3f..78f59cd8 100644 --- a/view/src/docview.ts +++ b/view/src/docview.ts @@ -6,11 +6,10 @@ import {Viewport, ViewportState} from "./viewport" import browser from "./browser" import {Text} from "../../doc/src" import {DOMObserver} from "./domobserver" -import {EditorState, ChangeSet, ChangedRange} from "../../state/src" +import {EditorState, ChangeSet, ChangedRange, Transaction} from "../../state/src" import {HeightMap, HeightOracle, MeasuredHeights, LineHeight} from "./heightmap" import {Decoration, DecorationSet, joinRanges, findChangedRanges, heightRelevantDecorations} from "./decoration" import {getRoot, clientRectsFor, isEquivalentPosition, scrollRectIntoView, maxOffset} from "./dom" -import {ViewUpdate} from "./editorview" type A = ReadonlyArray @@ -72,7 +71,7 @@ export class DocView extends ContentView { this.heightMap = HeightMap.empty().applyChanges([], this.heightOracle.setDoc(state.doc), changedRanges) this.children.length = this.viewports.length = 0 this.decorations = [] - let {viewport, contentChanges} = this.computeViewport(new ViewUpdate([], state, state, true), changedRanges, 0, -1) + let {viewport, contentChanges} = this.computeViewport(new ViewUpdate([], state, state, false), changedRanges, 0, -1) this.updateInner(contentChanges, 0, viewport) this.cancelLayoutCheck() this.callbacks.onUpdateDOM() @@ -345,7 +344,10 @@ export class DocView extends ContentView { return {viewport, contentChanges} } // Update the public viewport so that plugins can observe its current value - if (viewportChange) ({from: this.publicViewport._from, to: this.publicViewport._to} = viewport) + if (viewportChange) { + ({from: this.publicViewport._from, to: this.publicViewport._to} = viewport) + if (update) update.viewportChanged = true + } this.callbacks.onViewUpdate(update || new ViewUpdate([], this.state, this.state, true)) let decorations = this.callbacks.getDecorations() // If the decorations are stable, stop. @@ -736,3 +738,12 @@ export class EditorViewport { this.docView.heightMap.forEachLine(this.from, this.to, 0, this.docView.heightOracle, f) } } + +export class ViewUpdate { + // FIXME more fields (focus, dragging, ...) + // FIXME should scrollIntoView be stored in this? + constructor(public transactions: ReadonlyArray, + public oldState: EditorState, + public state: EditorState, + public viewportChanged: boolean) {} +} diff --git a/view/src/domobserver.ts b/view/src/domobserver.ts index a3981833..08177b54 100644 --- a/view/src/domobserver.ts +++ b/view/src/domobserver.ts @@ -29,7 +29,7 @@ export class DOMObserver { scrollTargets: HTMLElement[] = [] intersection: IntersectionObserver | null = null - intersecting: boolean = true + intersecting: boolean = false constructor(private docView: DocView, private onChange: (from: number, to: number, typeOver: boolean) => boolean, diff --git a/view/src/editorview.ts b/view/src/editorview.ts index 63151bf5..d0d6b58f 100644 --- a/view/src/editorview.ts +++ b/view/src/editorview.ts @@ -1,5 +1,6 @@ import {EditorState, Transaction, MetaSlot} from "../../state/src" -import {DocView, EditorViewport} from "./docview" +import {Extension, BehaviorStore} from "../../extension/src/extension" +import {DocView, EditorViewport, ViewUpdate} from "./docview" import {InputState, MouseSelectionUpdate} from "./input" import {getRoot, Rect} from "./dom" import {Decoration, DecorationSet} from "./decoration" @@ -7,6 +8,92 @@ import {applyDOMChange} from "./domchange" import {movePos, posAtCoords} from "./cursor" import {LineHeight} from "./heightmap" +export class ViewExtension extends Extension { + static state(spec: ViewStateSpec, slots: ViewSlot[] = []): ViewExtension { + if (slots.length == 0) return viewState(spec) + return viewStateWithSlots(spec, slots) + } + + static decorations(spec: ViewStateSpec & {map?: boolean}) { + let box = {value: Decoration.none}, map = spec.map !== false + return ViewExtension.all( + viewState({ + create(view) { + return box.value = spec.create(view) + }, + update(view, update, value) { + if (map) for (let tr of update.transactions) value = value.map(tr.changes) + return box.value = spec.update(view, update, value) + } + }), + decorationBehavior(box) + ) + } + + static defineSlot = defineViewSlot + + static decorationSlot: (accessor: (state: State) => DecorationSet) => ViewSlot = null as any + + static handleDOMEvents = ViewExtension.defineBehavior<{[key: string]: (view: EditorView, event: any) => boolean}>() + + static domEffect = ViewExtension.defineBehavior<(view: EditorView) => DOMEffect>() +} + +// FIXME does it make sense to isolate these from the actual view +// (only giving state, viewport etc)? +export interface ViewStateSpec { + create(view: EditorView): T + update(view: EditorView, update: ViewUpdate, value: T): T +} + +const viewState = ViewExtension.defineBehavior>() + +export type DOMEffect = { + update?: () => void + destroy?: () => void +} + +function defineViewSlot() { + let behavior = ViewExtension.defineBehavior<{value: T}>() + return { + // @internal + behavior, + get(view: EditorView): T[] { + return view.behavior.get(behavior).map(box => box.value) + }, + slot(accessor: (state: State) => T) { + return new ViewSlot(behavior, accessor) + } + } +} + +export class ViewSlot { + constructor(/* @internal */ public behavior: (value: any) => ViewExtension, + /* @internal */ public accessor: (state: State) => any) {} +} + +const {behavior: decorationBehavior, + get: getDecoratations, + slot: decorationSlot} = defineViewSlot() + +ViewExtension.decorationSlot = decorationSlot + +function viewStateWithSlots(spec: ViewStateSpec, slots: ViewSlot[]) { + let boxes = slots.map(slot => ({value: null})) + function save(value: any) { + for (let i = 0; i < slots.length; i++) + boxes[i].value = slots[i].accessor(value) + return value + } + return ViewExtension.all( + viewState({ + create(view) { return save(spec.create(view)) }, + update(view, update, value) { return save(spec.update(view, update, value)) } + }), + ...slots.map((slot, i) => slot.behavior(boxes[i])) + ) +} + export class EditorView { private _state!: EditorState get state(): EditorState { return this._state } @@ -24,11 +111,13 @@ export class EditorView { readonly viewport: EditorViewport - private pluginViews: PluginView[] = [] + public behavior!: BehaviorStore + private extState!: any[] + private domEffects: DOMEffect[] = [] private updatingState: boolean = false - constructor(state: EditorState, dispatch?: ((tr: Transaction) => void | null), ...plugins: PluginView[]) { + constructor(state: EditorState, dispatch?: ((tr: Transaction) => void | null), ...extensions: ViewExtension[]) { this.dispatch = dispatch || (tr => this.updateState([tr], tr.apply())) this.contentDOM = document.createElement("pre") @@ -45,25 +134,31 @@ export class EditorView { this.docView = new DocView(this.contentDOM, { onDOMChange: (start, end, typeOver) => applyDOMChange(this, start, end, typeOver), onViewUpdate: (update: ViewUpdate) => { - for (let pluginView of this.pluginViews) - if (pluginView.update) pluginView.update(this, update) + let specs = this.behavior.get(viewState) + for (let i = 0; i < specs.length; i++) + this.extState[i] = specs[i].update(this, update, this.extState[i]) }, onUpdateDOM: () => { - for (let plugin of this.pluginViews) if (plugin.updateDOM) plugin.updateDOM(this) + for (let spec of this.domEffects) if (spec.update) spec.update() }, - getDecorations: () => this.pluginViews.map(v => v.decorations || Decoration.none) + getDecorations: () => getDecoratations(this) }) this.viewport = this.docView.publicViewport - this.setState(state, ...plugins) + this.setState(state, ...extensions) } - setState(state: EditorState, ...plugins: PluginView[]) { + setState(state: EditorState, ...extensions: ViewExtension[]) { + for (let effect of this.domEffects) if (effect.destroy) effect.destroy() this._state = state this.withUpdating(() => { setTabSize(this.contentDOM, state.tabSize) - this.createPluginViews(plugins) + this.behavior = ViewExtension.resolve(extensions.concat(state.behavior.foreign)) + if (this.behavior.foreign.length) + throw new Error("Non-ViewExtension extensions found when setting view state") + this.extState = this.behavior.get(viewState).map(spec => spec.create(this)) this.inputState = new InputState(this) this.docView.init(state) + this.domEffects = this.behavior.get(ViewExtension.domEffect).map(spec => spec(this)) }) } @@ -76,31 +171,12 @@ export class EditorView { if (transactions.some(tr => tr.getMeta(MetaSlot.changeTabSize) != undefined)) setTabSize(this.contentDOM, state.tabSize) if (state.doc != prevState.doc || transactions.some(tr => tr.selectionSet && !tr.getMeta(MetaSlot.preserveGoalColumn))) this.inputState.goalColumns.length = 0 - this.docView.update(new ViewUpdate(transactions, prevState, state, true), + this.docView.update(new ViewUpdate(transactions, prevState, state, false), transactions.some(tr => tr.scrolledIntoView) ? state.selection.primary.head : -1) this.inputState.update(transactions) }) } - /** @internal */ - someProp(propName: N, f: (value: NonNullable) => R | undefined): R | undefined { - let value: R | undefined = undefined - for (let pluginView of this.pluginViews) { - let prop = pluginView[propName] - if (prop != null && (value = f(prop as NonNullable)) != null) break - } - return value - } - - /** @internal */ - getProp(propName: N): PluginView[N] { - for (let pluginView of this.pluginViews) { - let prop = pluginView[propName] - if (prop != null) return prop - } - return undefined - } - private withUpdating(f: () => void) { if (this.updatingState) throw new Error("Recursive calls of EditorView.updateState or EditorView.setState are not allowed") this.updatingState = true @@ -108,17 +184,10 @@ export class EditorView { finally { this.updatingState = false } } - private createPluginViews(plugins: PluginView[]) { - this.destroyPluginViews() - for (let plugin of plugins) this.pluginViews.push(plugin) - for (let plugin of this.state.plugins) if (plugin.view) - this.pluginViews.push(plugin.view(this)) - } - - private destroyPluginViews() { - for (let pluginView of this.pluginViews) if (pluginView.destroy) - pluginView.destroy() - this.pluginViews.length = 0 + extensionState(spec: ViewStateSpec): State | undefined { + let index = this.behavior.get(viewState).indexOf(spec) + if (index < 0) return undefined + return this.extState[index] } domAtPos(pos: number): {node: Node, offset: number} | null { @@ -173,31 +242,13 @@ export class EditorView { } destroy() { - this.destroyPluginViews() + for (let effect of this.domEffects) if (effect.destroy) effect.destroy() this.inputState.destroy() this.dom.remove() this.docView.destroy() } } -export class ViewUpdate { - // FIXME more fields (focus, dragging, ...) - // FIXME should scrollIntoView be stored in this? - constructor(public transactions: ReadonlyArray, - public oldState: EditorState, - public state: EditorState, - public viewportChanged: boolean) {} -} - -export interface PluginView { - update?: (view: EditorView, update: ViewUpdate) => void - updateDOM?: (view: EditorView) => void - handleDOMEvents?: {[key: string]: (view: EditorView, event: Event) => boolean} - // This should return a stable value, not compute something on the fly - decorations?: DecorationSet - destroy?: () => void -} - function setTabSize(elt: HTMLElement, size: number) { (elt.style as any).tabSize = (elt.style as any).MozTabSize = size } diff --git a/view/src/index.ts b/view/src/index.ts index 6834f839..c3509634 100644 --- a/view/src/index.ts +++ b/view/src/index.ts @@ -1,5 +1,5 @@ -export {EditorView, ViewUpdate, PluginView} from "./editorview" -export {EditorViewport} from "./docview" +export {EditorView, ViewExtension, ViewStateSpec, DOMEffect, ViewSlot} from "./editorview" +export {EditorViewport, ViewUpdate} from "./docview" export {Decoration, DecorationSet, DecoratedRange, WidgetType, RangeDecorationSpec, WidgetDecorationSpec, LineDecorationSpec} from "./decoration" export {LineHeight} from "./heightmap" diff --git a/view/src/input.ts b/view/src/input.ts index 8d8a2441..17cde8d9 100644 --- a/view/src/input.ts +++ b/view/src/input.ts @@ -1,5 +1,5 @@ import {MetaSlot, EditorSelection, SelectionRange, Transaction, ChangeSet, Change} from "../../state/src" -import {EditorView} from "./editorview" +import {EditorView, ViewExtension} from "./editorview" import browser from "./browser" import {LineContext} from "./cursor" @@ -192,10 +192,10 @@ function eventBelongsToEditor(view: EditorView, event: Event): boolean { function customHandlers(view: EditorView) { let result = Object.create(null) - view.someProp("handleDOMEvents", handlers => { + for (let handlers of view.behavior.get(ViewExtension.handleDOMEvents)) { for (let eventType in handlers) (result[eventType] || (result[eventType] = [])).push(handlers[eventType]) - }) + } return result } diff --git a/view/test/temp-editor.ts b/view/test/temp-editor.ts index 1c9dc5f0..8388855f 100644 --- a/view/test/temp-editor.ts +++ b/view/test/temp-editor.ts @@ -1,18 +1,19 @@ import {EditorView} from "../src" -import {EditorState, Plugin} from "../../state/src" +import {EditorState} from "../../state/src" +import {Extension} from "../../extension/src/extension" const workspace: HTMLElement = document.querySelector("#workspace")! as HTMLElement let tempView: EditorView | null = null let hide: any = null -export function tempEditor(doc = "", plugins: Plugin[] = []): EditorView { +export function tempEditor(doc = "", extensions: ReadonlyArray = []): EditorView { if (tempView) { tempView.destroy() tempView = null } - tempView = new EditorView(EditorState.create({doc, plugins})) + tempView = new EditorView(EditorState.create({doc, extensions})) workspace.appendChild(tempView.dom) workspace.style.pointerEvents = "" if (hide == null) hide = setTimeout(() => { diff --git a/view/test/test-composition.ts b/view/test/test-composition.ts index 0d94b18c..7dd41c05 100644 --- a/view/test/test-composition.ts +++ b/view/test/test-composition.ts @@ -1,6 +1,6 @@ import {tempEditor, requireFocus} from "./temp-editor" -import {EditorView, ViewUpdate, Decoration, DecorationSet, WidgetType} from "../src" -import {Plugin, EditorState} from "../../state/src" +import {EditorView, ViewExtension, Decoration, DecorationSet, WidgetType} from "../src" +import {EditorState} from "../../state/src" import ist from "ist" function event(cm: EditorView, type: string) { @@ -54,29 +54,20 @@ function wordDeco(state: EditorState): DecorationSet { return Decoration.set(deco) } -const wordHighlighter = new Plugin({ - view(v: EditorView) { - return { - decorations: wordDeco(v.state), - update() { this.decorations = wordDeco(v.state) } - } - } +const wordHighlighter = ViewExtension.decorations({ + create(view) { return wordDeco(view.state) }, + update(view) { return wordDeco(view.state) } }) function widgets(positions: number[], sides: number[]) { let xWidget = new class extends WidgetType { toDOM() { let s = document.createElement("var"); s.textContent = "×"; return s } }(null) - return new Plugin({ - view(v: EditorView) { - return { - decorations: Decoration.set( - positions.map((p, i) => Decoration.widget(p, {widget: xWidget, side: sides[i]}))), - update(_v: any, {transactions}: ViewUpdate) { - this.decorations = transactions.reduce((d, tr) => d.map(tr.changes), this.decorations) - } - } - } + return ViewExtension.decorations({ + create(v) { + return Decoration.set(positions.map((p, i) => Decoration.widget(p, {widget: xWidget, side: sides[i]}))) + }, + update(_v, _u, deco) { return deco } }) } diff --git a/view/test/test-domchange.ts b/view/test/test-domchange.ts index 0f0e61cd..d7540f27 100644 --- a/view/test/test-domchange.ts +++ b/view/test/test-domchange.ts @@ -1,6 +1,6 @@ import {tempEditor} from "./temp-editor" -import {EditorSelection, Plugin} from "../../state/src" -import {Decoration, EditorView, ViewUpdate} from "../src" +import {EditorSelection} from "../../state/src" +import {Decoration, EditorView, ViewExtension} from "../src" import ist from "ist" function flush(cm: EditorView) { @@ -119,10 +119,10 @@ describe("DOM changes", () => { }) it("doesn't drop collapsed text", () => { - let cm = tempEditor("abcd", [new Plugin({view: () => ({ - decorations: Decoration.set(Decoration.range(1, 3, {collapsed: true})), - update(v: EditorView, u: ViewUpdate) { if (u.transactions.length) (this as any).decorations = null } - })})]) + let cm = tempEditor("abcd", [ViewExtension.decorations({ + create() { return Decoration.set(Decoration.range(1, 3, {collapsed: true})) }, + update(v, u, d) { return u.transactions.length ? Decoration.none : d } + })]) cm.domAtPos(0)!.node.firstChild!.textContent = "x" flush(cm) ist(cm.state.doc.toString(), "xbcd") diff --git a/view/test/test-draw-decoration.ts b/view/test/test-draw-decoration.ts index c198e425..4800b250 100644 --- a/view/test/test-draw-decoration.ts +++ b/view/test/test-draw-decoration.ts @@ -1,6 +1,6 @@ -import {EditorView, Decoration, DecorationSet, WidgetType, DecoratedRange} from "../src/" +import {EditorView, ViewExtension, Decoration, DecorationSet, WidgetType, DecoratedRange} from "../src/" import {tempEditor, requireFocus} from "./temp-editor" -import {StateField, MetaSlot, Plugin, EditorSelection} from "../../state/src" +import {StateField, StateExtension, MetaSlot, EditorSelection} from "../../state/src" import ist from "ist" const filterSlot = new MetaSlot<(from: number, to: number, spec: any) => boolean>("filterDeco") @@ -16,14 +16,14 @@ function decos(startState: DecorationSet = Decoration.none) { return value } }) - return new Plugin({ - state: field, - view(editorView: EditorView) { - return { - get decorations() { return editorView.state.getField(field) } - } - } - }) + return [ + ViewExtension.decorations({ + create(v) { return v.state.getField(field) }, + update(v) { return v.state.getField(field) }, + map: false + }), + StateExtension.stateField(field) + ] } function d(from: number, to: any, spec: any = null) { @@ -39,7 +39,7 @@ function l(pos: number, attrs: any) { } function decoEditor(doc: string, decorations: any = []) { - return tempEditor(doc, [decos(Decoration.set(decorations))]) + return tempEditor(doc, decos(Decoration.set(decorations))) } describe("EditorView decoration", () => { diff --git a/view/test/test-draw.ts b/view/test/test-draw.ts index 9c173103..c94c44ca 100644 --- a/view/test/test-draw.ts +++ b/view/test/test-draw.ts @@ -148,4 +148,7 @@ describe("EditorView drawing", () => { ist(domText(cm), doc.slice(cm.viewport.from, cm.viewport.to)) } }) + + // FIXME add test that ensures an editor added to the dom after its + // initial checkLayout animationframe passed still updates itself. }) diff --git a/view/test/test-movepos.ts b/view/test/test-movepos.ts index f8207278..7ec7c0ff 100644 --- a/view/test/test-movepos.ts +++ b/view/test/test-movepos.ts @@ -1,6 +1,6 @@ import {tempEditor, requireFocus} from "./temp-editor" -import {EditorSelection, Plugin} from "../../state/src" -import {Decoration, WidgetType, EditorView} from "../src" +import {EditorSelection} from "../../state/src" +import {Decoration, WidgetType, ViewExtension} from "../src" import ist from "ist" const visualBidi = !/Edge\/(\d+)|MSIE \d|Trident\//.exec(navigator.userAgent) @@ -13,13 +13,14 @@ class OWidget extends WidgetType { } } -const oWidgets = new Plugin({ - view(view: EditorView) { +const oWidgets = ViewExtension.decorations({ + create(view) { let doc = view.state.doc.toString(), deco = [] for (let i = 0; i < doc.length; i++) if (doc.charAt(i) == "o") deco.push(Decoration.range(i, i + 1, {collapsed: new OWidget(undefined)})) - return {decorations: Decoration.set(deco)} - } + return Decoration.set(deco) + }, + update(view, update, deco) { return deco } }) class BigWidget extends WidgetType { @@ -115,11 +116,12 @@ describe("EditorView.movePos", () => { }) it("can cross large line widgets during line motion", () => { - let cm = tempEditor("one\ntwo", [new Plugin({ - view() { return {decorations: Decoration.set([ + let cm = tempEditor("one\ntwo", [ViewExtension.decorations({ + create() { return Decoration.set([ Decoration.line(0, {widget: new BigWidget(undefined), side: 1}), Decoration.line(4, {widget: new BigWidget(undefined), side: -1}) - ])} } + ]) }, + update(v, u, deco) { return deco } })]) ist(cm.contentDOM.offsetHeight, 400, ">") ist(cm.movePos(0, "forward", "line"), 4) diff --git a/view/test/test-plugin.ts b/view/test/test-plugin.ts index 323062fe..b0681053 100644 --- a/view/test/test-plugin.ts +++ b/view/test/test-plugin.ts @@ -1,37 +1,29 @@ import {tempEditor} from "./temp-editor" -import {EditorSelection, Plugin} from "../../state/src" -import {EditorView, ViewUpdate} from "../src/" +import {EditorSelection} from "../../state/src" +import {EditorView, ViewUpdate, ViewExtension} from "../src/" import ist from "ist" -describe("EditorView plugins", () => { - it("calls update on transactions", () => { - let called = 0 - let cm = tempEditor("one\ntwo", [new Plugin({view: (view: EditorView) => { - let doc = view.state.doc.toString() - ist(doc, "one\ntwo") - return { - update(view: EditorView, update: ViewUpdate) { - ist(update.oldState.doc.toString(), doc) - doc = view.state.doc.toString() - if (update.transactions.length == 1) called++ - } +describe("EditorView extension", () => { + it("can maintain state", () => { + let spec = { + create(view: EditorView) { return [view.state.doc.toString()] }, + update(view: EditorView, update: ViewUpdate, value: string[]) { + return update.transactions.length ? value.concat(view.state.doc.toString()) : value } - }})]) + } + let cm = tempEditor("one\ntwo", [ViewExtension.state(spec)]) cm.dispatch(cm.state.transaction.replace(0, 1, "O")) cm.dispatch(cm.state.transaction.replace(4, 5, "T")) cm.dispatch(cm.state.transaction.setSelection(EditorSelection.single(1))) - ist(called, 3) + ist(cm.extensionState(spec)!.join("/"), "one\ntwo/One\ntwo/One\nTwo/One\nTwo") }) it("calls update when the viewport changes", () => { let ports: number[][] = [] - let cm = tempEditor("x\n".repeat(500), [new Plugin({view: () => { - return { - update(view: EditorView) { - ports.push([view.viewport.from, view.viewport.to]) - } - } - }})]) + let cm = tempEditor("x\n".repeat(500), [ViewExtension.state({ + create() {}, + update(view) { ports.push([view.viewport.from, view.viewport.to]) } + })]) ist(ports.length, 1) ist(ports[0][0], 0) cm.dom.style.height = "300px" @@ -46,13 +38,11 @@ describe("EditorView plugins", () => { ist(ports.length, 3) }) - it("calls updateDOM when the DOM is changed", () => { + it("calls update on DOM effects when the DOM is changed", () => { let updates = 0 - let cm = tempEditor("xyz", [new Plugin({view: () => { - return { - updateDOM() { updates++ } - } - }})]) + let cm = tempEditor("xyz", [ViewExtension.domEffect(() => ({ + update() { updates++ } + }))]) ist(updates, 1) cm.dispatch(cm.state.transaction.replace(1, 2, "u")) ist(updates, 2)