268 lines
9.3 KiB
TypeScript
Executable File
268 lines
9.3 KiB
TypeScript
Executable File
import {tempEditor, requireFocus} from "./temp-editor"
|
||
import {EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, WidgetType} from "@codemirror/next/view"
|
||
import {EditorState} from "@codemirror/next/state"
|
||
import ist from "ist"
|
||
|
||
function event(cm: EditorView, type: string) {
|
||
cm.contentDOM.dispatchEvent(new CompositionEvent(type))
|
||
}
|
||
|
||
function up(node: Text, text: string = "", from = node.nodeValue!.length, to = from) {
|
||
let val = node.nodeValue!
|
||
node.nodeValue = val.slice(0, from) + text + val.slice(to)
|
||
document.getSelection()!.collapse(node, from + text.length)
|
||
return node
|
||
}
|
||
|
||
function hasCompositionDeco(cm: EditorView) {
|
||
return cm.docView.compositionDeco.size > 0
|
||
}
|
||
|
||
function compose(cm: EditorView, start: () => Text,
|
||
update: ((node: Text) => void)[],
|
||
options: {end?: (node: Text) => void, cancel?: boolean} = {}) {
|
||
event(cm, "compositionstart")
|
||
let node!: Text, sel = document.getSelection()!
|
||
for (let i = -1; i < update.length; i++) {
|
||
if (i < 0) node = start()
|
||
else update[i](node)
|
||
let {focusNode, focusOffset} = sel
|
||
cm.observer.flush()
|
||
|
||
if (options.cancel && i == update.length - 1) {
|
||
ist(!hasCompositionDeco(cm))
|
||
} else {
|
||
ist(node.parentNode && cm.contentDOM.contains(node.parentNode))
|
||
ist(sel.focusNode, focusNode)
|
||
ist(sel.focusOffset, focusOffset)
|
||
ist(hasCompositionDeco(cm))
|
||
}
|
||
}
|
||
event(cm, "compositionend")
|
||
if (options.end) options.end(node)
|
||
cm.observer.flush()
|
||
cm.update([])
|
||
ist(!cm.inputState.composing)
|
||
ist(!hasCompositionDeco(cm))
|
||
}
|
||
|
||
function wordDeco(state: EditorState): DecorationSet {
|
||
let re = /\w+/g, m, deco = [], text = state.doc.toString()
|
||
while (m = re.exec(text))
|
||
deco.push(Decoration.mark({class: "word"}).range(m.index, m.index + m[0].length))
|
||
return Decoration.set(deco)
|
||
}
|
||
|
||
const wordHighlighter = EditorView.decorations.compute(["doc"], wordDeco)
|
||
|
||
function widgets(positions: number[], sides: number[]): ViewPlugin<any> {
|
||
let xWidget = new class extends WidgetType<null> {
|
||
toDOM() { let s = document.createElement("var"); s.textContent = "×"; return s }
|
||
}(null)
|
||
let startDeco = Decoration.set(positions.map((p, i) => Decoration.widget({widget: xWidget, side: sides[i]}).range(p)))
|
||
return ViewPlugin.define(() => ({
|
||
decorations: startDeco,
|
||
update(update: ViewUpdate) { this.decorations = this.decorations.map(update.changes) }
|
||
})).decorations()
|
||
}
|
||
|
||
describe("Composition", () => {
|
||
it("supports composition on an empty line", () => {
|
||
let cm = requireFocus(tempEditor("foo\n\nbar"))
|
||
compose(cm, () => up(cm.domAtPos(4).node.appendChild(document.createTextNode("a"))), [
|
||
n => up(n, "b"),
|
||
n => up(n, "c")
|
||
])
|
||
ist(cm.state.doc.toString(), "foo\nabc\nbar")
|
||
})
|
||
|
||
it("supports composition at end of line in existing node", () => {
|
||
let cm = requireFocus(tempEditor("foo"))
|
||
compose(cm, () => up(cm.domAtPos(2).node as Text), [
|
||
n => up(n, "!"),
|
||
n => up(n, "?")
|
||
])
|
||
ist(cm.state.doc.toString(), "foo!?")
|
||
})
|
||
|
||
it("supports composition at end of line in a new node", () => {
|
||
let cm = requireFocus(tempEditor("foo"))
|
||
compose(cm, () => up(cm.domAtPos(0).node.appendChild(document.createTextNode("!"))), [
|
||
n => up(n, "?")
|
||
])
|
||
ist(cm.state.doc.toString(), "foo!?")
|
||
})
|
||
|
||
it("supports composition at start of line in a new node", () => {
|
||
let cm = requireFocus(tempEditor("foo"))
|
||
compose(cm, () => {
|
||
let l0 = cm.domAtPos(0).node
|
||
return up(l0.insertBefore(document.createTextNode("!"), l0.firstChild))
|
||
}, [
|
||
n => up(n, "?")
|
||
])
|
||
ist(cm.state.doc.toString(), "!?foo")
|
||
})
|
||
|
||
it("supports composition inside existing text", () => {
|
||
let cm = requireFocus(tempEditor("foo"))
|
||
compose(cm, () => up(cm.domAtPos(2).node as Text), [
|
||
n => up(n, "x", 1),
|
||
n => up(n, "y", 2),
|
||
n => up(n, "z", 3)
|
||
])
|
||
ist(cm.state.doc.toString(), "fxyzoo")
|
||
})
|
||
|
||
it("can deal with Android-style newline-after-composition", () => {
|
||
let cm = requireFocus(tempEditor("abcdef"))
|
||
compose(cm, () => up(cm.domAtPos(2).node as Text), [
|
||
n => up(n, "x", 3),
|
||
n => up(n, "y", 4)
|
||
], {end: n => {
|
||
let line = n.parentNode!.appendChild(document.createElement("div"))
|
||
line.textContent = "def"
|
||
n.nodeValue = "abcxy"
|
||
document.getSelection()!.collapse(line, 0)
|
||
}})
|
||
ist(cm.state.doc.toString(), "abcxy\ndef")
|
||
})
|
||
|
||
it("handles replacement of existing words", () => {
|
||
let cm = requireFocus(tempEditor("one two three"))
|
||
compose(cm, () => up(cm.domAtPos(1).node as Text, "five", 4, 7), [
|
||
n => up(n, "seven", 4, 8),
|
||
n => up(n, "zero", 4, 9)
|
||
])
|
||
ist(cm.state.doc.toString(), "one zero three")
|
||
})
|
||
|
||
it("doesn't get interrupted by changes in decorations", () => {
|
||
let cm = requireFocus(tempEditor("foo ...", [wordHighlighter]))
|
||
compose(cm, () => up(cm.domAtPos(5).node as Text), [
|
||
n => up(n, "hi", 1, 4)
|
||
])
|
||
ist(cm.state.doc.toString(), "foo hi")
|
||
})
|
||
|
||
it("works inside highlighted text", () => {
|
||
let cm = requireFocus(tempEditor("one two", [wordHighlighter]))
|
||
compose(cm, () => up(cm.domAtPos(1).node as Text, "x"), [
|
||
n => up(n, "y"),
|
||
n => up(n, ".")
|
||
])
|
||
ist(cm.state.doc.toString(), "onexy. two")
|
||
})
|
||
|
||
it("can handle compositions spanning multiple tokens", () => {
|
||
let cm = requireFocus(tempEditor("one two", [wordHighlighter]))
|
||
compose(cm, () => up(cm.domAtPos(5).node as Text, "a"), [
|
||
n => up(n, "b"),
|
||
n => up(n, "c")
|
||
], {end: n => {
|
||
;(n.parentNode!.previousSibling! as ChildNode).remove()
|
||
;(n.parentNode!.previousSibling! as ChildNode).remove()
|
||
return up(n, "xyzone ", 0)
|
||
}})
|
||
ist(cm.state.doc.toString(), "xyzone twoabc")
|
||
})
|
||
|
||
it("doesn't overwrite widgets next to the composition", () => {
|
||
let cm = requireFocus(tempEditor("", [widgets([0, 0], [-1, 1])]))
|
||
compose(cm, () => {
|
||
let l0 = cm.domAtPos(0).node
|
||
return up(l0.insertBefore(document.createTextNode("a"), l0.lastChild))
|
||
}, [n => up(n, "b", 0, 1)], {end: () => {
|
||
ist(cm.contentDOM.querySelectorAll("var").length, 2)
|
||
}})
|
||
ist(cm.state.doc.toString(), "b")
|
||
})
|
||
|
||
it("cancels composition when a change fully overlaps with it", () => {
|
||
let cm = requireFocus(tempEditor("one\ntwo\nthree"))
|
||
compose(cm, () => up(cm.domAtPos(5).node as Text, "x"), [
|
||
() => cm.dispatch(cm.state.t().replace(2, 10, "---"))
|
||
], {cancel: true})
|
||
ist(cm.state.doc.toString(), "on---hree")
|
||
})
|
||
|
||
it("cancels composition when a change partially overlaps with it", () => {
|
||
let cm = requireFocus(tempEditor("one\ntwo\nthree"))
|
||
compose(cm, () => up(cm.domAtPos(5).node as Text, "x", 0), [
|
||
() => cm.dispatch(cm.state.t().replace(5, 12, "---"))
|
||
], {cancel: true})
|
||
ist(cm.state.doc.toString(), "one\nx---ee")
|
||
})
|
||
|
||
it("cancels composition when a change happens inside of it", () => {
|
||
let cm = requireFocus(tempEditor("one\ntwo\nthree"))
|
||
compose(cm, () => up(cm.domAtPos(5).node as Text, "x", 0), [
|
||
() => cm.dispatch(cm.state.t().replace(5, 6, "!"))
|
||
], {cancel: true})
|
||
ist(cm.state.doc.toString(), "one\nx!wo\nthree")
|
||
})
|
||
|
||
it("doesn't cancel composition when a change happens elsewhere", () => {
|
||
let cm = requireFocus(tempEditor("one\ntwo\nthree"))
|
||
compose(cm, () => up(cm.domAtPos(5).node as Text, "x", 0), [
|
||
n => up(n, "y", 1),
|
||
() => cm.dispatch(cm.state.t().replace(1, 2, "!")),
|
||
n => up(n, "z", 2)
|
||
])
|
||
ist(cm.state.doc.toString(), "o!e\nxyztwo\nthree")
|
||
})
|
||
|
||
it("doesn't cancel composition when the composition is moved into a new line", () => {
|
||
let cm = requireFocus(tempEditor("one\ntwo three", [wordHighlighter]))
|
||
compose(cm, () => up(cm.domAtPos(9).node as Text, "x"), [
|
||
n => up(n, "y"),
|
||
() => cm.dispatch(cm.state.t().replace(4, 4, "\n")),
|
||
n => up(n, "z")
|
||
])
|
||
ist(cm.state.doc.toString(), "one\n\ntwo threexyz")
|
||
})
|
||
|
||
it("doesn't cancel composition when a line break is inserted in front of it", () => {
|
||
let cm = requireFocus(tempEditor("one two three", [wordHighlighter]))
|
||
compose(cm, () => up(cm.domAtPos(9).node as Text, "x"), [
|
||
n => up(n, "y"),
|
||
() => cm.dispatch(cm.state.t().replace(8, 8, "\n")),
|
||
n => up(n, "z")
|
||
])
|
||
ist(cm.state.doc.toString(), "one two \nthreexyz")
|
||
})
|
||
|
||
it("doesn't cancel composition when a newline is added immediately in front", () => {
|
||
let cm = requireFocus(tempEditor("one\ntwo three", [wordHighlighter]))
|
||
compose(cm, () => up(cm.domAtPos(9).node as Text, "x"), [
|
||
n => up(n, "y"),
|
||
() => cm.dispatch(cm.state.t().replace(7, 8, "\n")),
|
||
n => up(n, "z")
|
||
])
|
||
ist(cm.state.doc.toString(), "one\ntwo\nthreexyz")
|
||
})
|
||
|
||
it("handles compositions rapidly following each other", () => {
|
||
let cm = requireFocus(tempEditor("one\ntwo"))
|
||
event(cm, "compositionstart")
|
||
let one = cm.domAtPos(1).node as Text
|
||
up(one, "!")
|
||
cm.observer.flush()
|
||
event(cm, "compositionend")
|
||
one.nodeValue = "one!!"
|
||
let L2 = cm.contentDOM.lastChild
|
||
event(cm, "compositionstart")
|
||
let two = cm.domAtPos(7).node as Text
|
||
ist(cm.contentDOM.lastChild, L2)
|
||
up(two, ".")
|
||
cm.observer.flush()
|
||
ist(hasCompositionDeco(cm))
|
||
ist(getSelection()!.focusNode, two)
|
||
ist(getSelection()!.focusOffset, 4)
|
||
ist(cm.inputState.composing)
|
||
event(cm, "compositionend")
|
||
cm.observer.flush()
|
||
ist(cm.state.doc.toString(), "one!!\ntwo.")
|
||
})
|
||
})
|