From 3f1a120f382fa422929001ffa8f7cee230d9af80 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 9 Apr 2024 12:13:20 +0200 Subject: [PATCH 1/6] Start trying to integrate EditContext --- src/docview.ts | 30 ++++++++++++++---- src/domchange.ts | 68 +++++++++++++++++++++++----------------- src/domobserver.ts | 74 ++++++++++++++++++++++++++++++++++++++++++-- src/editcontext.d.ts | 60 +++++++++++++++++++++++++++++++++++ src/editorview.ts | 3 +- src/extension.ts | 3 ++ src/input.ts | 4 +++ 7 files changed, 204 insertions(+), 38 deletions(-) create mode 100644 src/editcontext.d.ts diff --git a/src/docview.ts b/src/docview.ts index 48924f9e..355646dc 100644 --- a/src/docview.ts +++ b/src/docview.ts @@ -1,4 +1,4 @@ -import {ChangeSet, RangeSet, findClusterBreak, SelectionRange} from "@codemirror/state" +import {ChangeSet, RangeSet, Range, findClusterBreak, SelectionRange} from "@codemirror/state" import {ContentView, ChildCursor, ViewFlag, DOMPos, replaceRange} from "./contentview" import {BlockView, LineView, BlockWidgetView} from "./blockview" import {TextView, MarkView} from "./inlineview" @@ -9,8 +9,8 @@ import {getAttrs} from "./attributes" import {clientRectsFor, isEquivalentPosition, Rect, scrollRectIntoView, getSelection, hasSelection, textRange, DOMSelectionState, textNodeBefore, textNodeAfter} from "./dom" -import {ViewUpdate, decorations as decorationsFacet, outerDecorations, - ChangedRange, ScrollTarget, scrollHandler, getScrollMargins, logException} from "./extension" +import {ViewUpdate, decorations as decorationsFacet, outerDecorations, ChangedRange, + ScrollTarget, scrollHandler, getScrollMargins, logException, setEditContextFormatting} from "./extension" import {EditorView} from "./editorview" import {Direction} from "./bidi" @@ -25,10 +25,11 @@ export class DocView extends ContentView { children!: BlockView[] decorations: readonly DecorationSet[] = [] - dynamicDecorationMap: boolean[] = [] + dynamicDecorationMap: boolean[] = [false] domChanged: {newSel: SelectionRange | null} | null = null hasComposition: {from: number, to: number} | null = null markedForComposition: Set = new Set + editContextFormatting = Decoration.none lastCompositionAfterCursor = false // Track a minimum width for the editor. When measuring sizes in @@ -77,8 +78,10 @@ export class DocView extends ContentView { } } + this.updateEditContextFormatting(update) + let readCompositionAt = -1 - if (this.view.inputState.composing >= 0) { + if (this.view.inputState.composing >= 0 && !this.view.observer.editContext) { if (this.domChanged?.newSel) readCompositionAt = this.domChanged.newSel.head else if (!touchesComposition(update.changes, this.hasComposition) && !update.selectionSet) @@ -186,6 +189,20 @@ export class DocView extends ContentView { if (composition) this.fixCompositionDOM(composition) } + private updateEditContextFormatting(update: ViewUpdate) { + this.editContextFormatting = this.editContextFormatting.map(update.changes) + for (let tr of update.transactions) for (let effect of tr.effects) if (effect.is(setEditContextFormatting)) { + this.editContextFormatting = Decoration.set(effect.value.map(format => { + let lineStyle = format.underlineStyle, thickness = format.underlineThickness + if (lineStyle == "None" || thickness == "None") return null + let style = `text-decoration: underline ${ + lineStyle == "Dashed" ? "dashed " : lineStyle == "Squiggle" ? "wavy " : "" + }${thickness == "Thin" ? 1 : 2}px` + return Decoration.mark({attributes: {style}}).range(format.rangeStart, format.rangeEnd) + }).filter(x => x) as Range[]) + } + } + private compositionView(composition: Composition) { let cur: ContentView = new TextView(composition.text.nodeValue!) cur.flags |= ViewFlag.Composition @@ -501,7 +518,7 @@ export class DocView extends ContentView { } updateDeco() { - let i = 0 + let i = 1 let allDeco = this.view.state.facet(decorationsFacet).map(d => { let dynamic = this.dynamicDecorationMap[i++] = typeof d == "function" return dynamic ? (d as (view: EditorView) => DecorationSet)(this.view) : d as DecorationSet @@ -516,6 +533,7 @@ export class DocView extends ContentView { allDeco.push(RangeSet.join(outerDeco)) } this.decorations = [ + this.editContextFormatting, ...allDeco, this.computeBlockGapDeco(), this.view.viewState.lineGapDeco diff --git a/src/domchange.ts b/src/domchange.ts index 1aa80bf0..f862a051 100644 --- a/src/domchange.ts +++ b/src/domchange.ts @@ -116,35 +116,7 @@ export function applyDOMChange(view: EditorView, domChange: DOMChange): boolean } if (change) { - if (browser.ios && view.inputState.flushIOSKey(change)) return true - // Android browsers don't fire reasonable key events for enter, - // backspace, or delete. So this detects changes that look like - // they're caused by those keys, and reinterprets them as key - // events. (Some of these keys are also handled by beforeinput - // events and the pendingAndroidKey mechanism, but that's not - // reliable in all situations.) - if (browser.android && - ((change.to == sel.to && - // GBoard will sometimes remove a space it just inserted - // after a completion when you press enter - (change.from == sel.from || change.from == sel.from - 1 && view.state.sliceDoc(change.from, sel.from) == " ") && - change.insert.length == 1 && change.insert.lines == 2 && - dispatchKey(view.contentDOM, "Enter", 13)) || - ((change.from == sel.from - 1 && change.to == sel.to && change.insert.length == 0 || - lastKey == 8 && change.insert.length < change.to - change.from && change.to > sel.head) && - dispatchKey(view.contentDOM, "Backspace", 8)) || - (change.from == sel.from && change.to == sel.to + 1 && change.insert.length == 0 && - dispatchKey(view.contentDOM, "Delete", 46)))) - return true - - let text = change.insert.toString() - if (view.inputState.composing >= 0) view.inputState.composing++ - - let defaultTr: Transaction | null - let defaultInsert = () => defaultTr || (defaultTr = applyDefaultInsert(view, change!, newSel)) - if (!view.state.facet(inputHandler).some(h => h(view, change!.from, change!.to, text, defaultInsert))) - view.dispatch(defaultInsert()) - return true + return applyDOMChangeInner(view, change, newSel, lastKey) } else if (newSel && !newSel.main.eq(sel)) { let scrollIntoView = false, userEvent = "select" if (view.inputState.lastSelectionTime > Date.now() - 50) { @@ -158,6 +130,44 @@ export function applyDOMChange(view: EditorView, domChange: DOMChange): boolean } } +export function applyDOMChangeInner( + view: EditorView, + change: {from: number, to: number, insert: Text}, + newSel: EditorSelection | null, + lastKey: number = -1 +): boolean { + if (browser.ios && view.inputState.flushIOSKey(change)) return true + let sel = view.state.selection.main + // Android browsers don't fire reasonable key events for enter, + // backspace, or delete. So this detects changes that look like + // they're caused by those keys, and reinterprets them as key + // events. (Some of these keys are also handled by beforeinput + // events and the pendingAndroidKey mechanism, but that's not + // reliable in all situations.) + if (browser.android && + ((change.to == sel.to && + // GBoard will sometimes remove a space it just inserted + // after a completion when you press enter + (change.from == sel.from || change.from == sel.from - 1 && view.state.sliceDoc(change.from, sel.from) == " ") && + change.insert.length == 1 && change.insert.lines == 2 && + dispatchKey(view.contentDOM, "Enter", 13)) || + ((change.from == sel.from - 1 && change.to == sel.to && change.insert.length == 0 || + lastKey == 8 && change.insert.length < change.to - change.from && change.to > sel.head) && + dispatchKey(view.contentDOM, "Backspace", 8)) || + (change.from == sel.from && change.to == sel.to + 1 && change.insert.length == 0 && + dispatchKey(view.contentDOM, "Delete", 46)))) + return true + + let text = change.insert.toString() + if (view.inputState.composing >= 0) view.inputState.composing++ + + let defaultTr: Transaction | null + let defaultInsert = () => defaultTr || (defaultTr = applyDefaultInsert(view, change!, newSel)) + if (!view.state.facet(inputHandler).some(h => h(view, change!.from, change!.to, text, defaultInsert))) + view.dispatch(defaultInsert()) + return true +} + function applyDefaultInsert(view: EditorView, change: {from: number, to: number, insert: Text}, newSel: EditorSelection | null): Transaction { let tr: TransactionSpec, startState = view.state, sel = startState.selection.main diff --git a/src/domobserver.ts b/src/domobserver.ts index 80cf2a27..da20edee 100644 --- a/src/domobserver.ts +++ b/src/domobserver.ts @@ -1,10 +1,12 @@ import browser from "./browser" import {ContentView, ViewFlag} from "./contentview" import {EditorView} from "./editorview" -import {editable} from "./extension" +import {editable, ViewUpdate, setEditContextFormatting, MeasureRequest} from "./extension" import {hasSelection, getSelection, DOMSelectionState, isEquivalentPosition, deepActiveElement, dispatchKey, atElementStart} from "./dom" -import {DOMChange, applyDOMChange} from "./domchange" +import {DOMChange, applyDOMChange, applyDOMChangeInner} from "./domchange" +import type {EditContext, TextUpdateEvent} from "./editcontext" +import {Text, EditorSelection} from "@codemirror/state" const observeOptions = { childList: true, @@ -25,6 +27,9 @@ export class DOMObserver { observer: MutationObserver active: boolean = false + editContext: EditContext | null = null + editContextMeasure: MeasureRequest | null = null + // The known selection. Kept in our own object, as opposed to just // directly accessing the selection because: // - Safari doesn't report the right selection in shadow DOM @@ -76,6 +81,9 @@ export class DOMObserver { this.flush() }) + if (window.EditContext) + view.contentDOM.editContext = this.editContext = this.createEditContext() + if (useCharData) this.onCharData = (event: MutationEvent) => { this.queue.push({target: event.target, @@ -119,6 +127,41 @@ export class DOMObserver { this.readSelectionRange() } + createEditContext() { + let {view} = this + let context = new window.EditContext({ + text: view.state.doc.toString(), // FIXME window? + selectionStart: view.state.selection.main.anchor, + selectionEnd: view.state.selection.main.head + }) + context.addEventListener("textupdate", e => this.onTextUpdate(e)) + context.addEventListener("characterboundsupdate", e => { + let rects: DOMRect[] = [], prev: DOMRect | null = null + for (let i = e.rangeStart; i < e.rangeEnd; i++) { + let rect = view.coordsForChar(i) + prev = (rect && new DOMRect(rect.left, rect.right, rect.right - rect.left, rect.bottom - rect.top)) + || prev || new DOMRect + rects.push(prev) + } + context.updateCharacterBounds(e.rangeStart, rects) + }) + context.addEventListener("textformatupdate", e => { + this.view.dispatch({effects: setEditContextFormatting.of(e.getTextFormats())}) + }) + context.addEventListener("compositionstart", () => { + if (view.inputState.composing < 0) view.inputState.composing = 0 + }) + context.addEventListener("compositionend", () => view.inputState.composing = -1) + + this.editContextMeasure = {read: view => { + this.editContext!.updateControlBounds(view.contentDOM.getBoundingClientRect()) + let sel = getSelection(view.root) + if (sel && sel.rangeCount) + this.editContext!.updateSelectionBounds(sel.getRangeAt(0).getBoundingClientRect()) + }} + return context + } + onScrollChanged(e: Event) { this.view.inputState.runHandlers("scroll", e) if (this.intersecting) this.view.measure() @@ -126,6 +169,7 @@ export class DOMObserver { onScroll(e: Event) { if (this.intersecting) this.flush(false) + if (this.editContext) this.view.requestMeasure(this.editContextMeasure!) this.onScrollChanged(e) } @@ -238,6 +282,16 @@ export class DOMObserver { } } + onTextUpdate(e: TextUpdateEvent) { + let change = {from: e.updateRangeStart, to: e.updateRangeEnd, insert: Text.of(e.text.split("\n"))} + // Undo the change in the edit context right away so that we can + // safely apply our transaction (which may make a different + // change, more changes, or no changes at all) to it later. + this.editContext!.updateText(change.from, change.from + change.insert.length, + this.view.state.doc.sliceString(change.from, change.to)) + applyDOMChangeInner(this.view, change, EditorSelection.single(e.selectionStart, e.selectionEnd)) + } + ignore(f: () => T): T { if (!this.active) return f() try { @@ -426,6 +480,22 @@ export class DOMObserver { win.document.removeEventListener("selectionchange", this.onSelectionChange) } + update(update: ViewUpdate) { + if (this.editContext) { + for (let tr of update.transactions) { + tr.changes.iterChanges((fromA, toA, fromB, toB, insert) => { + this.editContext!.updateText(fromA, toA, insert.toString()) + }) + } + if (update.docChanged || update.selectionSet) { + let {main} = update.state.selection + this.editContext.updateSelection(main.anchor, main.head) + } + } + if (this.editContext && (update.geometryChanged || update.docChanged || update.selectionSet)) + this.view.requestMeasure(this.editContextMeasure!) + } + destroy() { this.stop() this.intersection?.disconnect() diff --git a/src/editcontext.d.ts b/src/editcontext.d.ts new file mode 100644 index 00000000..4831967e --- /dev/null +++ b/src/editcontext.d.ts @@ -0,0 +1,60 @@ +export interface TextUpdateEvent extends Event { + readonly updateRangeStart: number + readonly updateRangeEnd: number + readonly text: string + readonly selectionStart: number + readonly selectionEnd: number +} + +export interface TextFormat { + readonly rangeStart: number + readonly rangeEnd: number + readonly textColor: string + readonly backgroundColor: string + readonly underlineStyle: "None" | "Solid" | "Dotted" | "Dashed" | "Squiggle" + readonly underlineThickness: "None" | "Thin" | "Thick" + readonly underlineColor: string +} + +export interface TextFormatUpdateEvent extends Event { + getTextFormats(): readonly TextFormat[] +} + +export interface CharacterBoundsUpdateEvent extends Event { + readonly rangeStart: number + readonly rangeEnd: number +} + +export declare class EditContext { + constructor(options?: {text?: string, selectionStart?: number, selectionEnd?: number}) + + updateText(rangeStart: number, rangeEnd: number, text: string): void + updateSelection(start: number, end: number): void + updateControlBounds(controlBound: DOMRect): void + updateSelectionBounds(selectionBound: DOMRect): void + updateCharacterBounds(rangeStart: number, characterBounds: readonly DOMRect[]): void + + attachedElements(): readonly Element[] + + readonly text: string + readonly selectionStart: number + readonly selectionEnd: number + readonly compositionRangeStart: number + readonly compositionRangeEnd: number + readonly isInComposition: boolean + readonly controlBound: DOMRect + readonly selectionBound: DOMRect + readonly characterBoundsRangeStart: number + characterBounds(): readonly DOMRect[] + + addEventListener(type: "textupdate", handler: (event: TextUpdateEvent) => void): void + addEventListener(type: "textformatupdate", handler: (event: TextFormatUpdateEvent) => void): void + addEventListener(type: "characterboundsupdate", handler: (event: CharacterBoundsUpdateEvent) => void): void + addEventListener(type: "compositionstart", handler: (event: CompositionEvent) => void): void + addEventListener(type: "compositionend", handler: (event: CompositionEvent) => void): void +} + +declare global { + interface HTMLElement { editContext: EditContext | null } + interface Window { EditContext: typeof EditContext } +} diff --git a/src/editorview.ts b/src/editorview.ts index 403d1b81..21ab4e68 100644 --- a/src/editorview.ts +++ b/src/editorview.ts @@ -496,7 +496,8 @@ export class EditorView { this.measureScheduled = -1 } - if (updated && !updated.empty) for (let listener of this.state.facet(updateListener)) listener(updated) + if (updated && !updated.empty) + for (let listener of this.state.facet(updateListener)) listener(updated) } /// Get the CSS classes for the currently active editor themes. diff --git a/src/extension.ts b/src/extension.ts index 6a573b03..d13bcfbe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,7 @@ import {Attrs} from "./attributes" import {Isolate, autoDirection} from "./bidi" import {Rect, ScrollStrategy} from "./dom" import {MakeSelectionStyle} from "./input" +import {TextFormat} from "./editcontext" /// Command functions are used in key bindings and other types of user /// actions. Given an editor view, they check whether their effect can @@ -73,6 +74,8 @@ export class ScrollTarget { export const scrollIntoView = StateEffect.define({map: (t, ch) => t.map(ch)}) +export const setEditContextFormatting = StateEffect.define() + /// Log or report an unhandled exception in client code. Should /// probably only be used by extension code that allows client code to /// provide functions, and calls those functions in a context where an diff --git a/src/input.ts b/src/input.ts index e1d37b21..b77b3173 100644 --- a/src/input.ts +++ b/src/input.ts @@ -173,6 +173,7 @@ export class InputState { } update(update: ViewUpdate) { + this.view.observer.update(update) if (this.mouseSelection) this.mouseSelection.update(update) if (this.draggedContent && update.docChanged) this.draggedContent = this.draggedContent.map(update.changes) if (update.transactions.length) this.lastKeyCode = this.lastSelectionTime = 0 @@ -808,6 +809,7 @@ observers.blur = view => { } observers.compositionstart = observers.compositionupdate = view => { + if (view.observer.editContext) return // Composition handled by edit context if (view.inputState.compositionFirstChange == null) view.inputState.compositionFirstChange = true if (view.inputState.composing < 0) { @@ -817,6 +819,8 @@ observers.compositionstart = observers.compositionupdate = view => { } observers.compositionend = view => { + if (view.observer.editContext) return // Composition handled by edit context + // FIXME check if any of these hacks are needed with edit context view.inputState.composing = -1 view.inputState.compositionEndedAt = Date.now() view.inputState.compositionPendingKey = true From e71f875ac816053ed5bf4cd86c21f942b3dc7fa0 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Wed, 10 Apr 2024 18:15:25 +0200 Subject: [PATCH 2/6] Add viewporting for the edit context --- src/docview.ts | 11 +-- src/domobserver.ts | 208 +++++++++++++++++++++++++++++++-------------- src/extension.ts | 3 +- 3 files changed, 146 insertions(+), 76 deletions(-) diff --git a/src/docview.ts b/src/docview.ts index 355646dc..f5447aff 100644 --- a/src/docview.ts +++ b/src/docview.ts @@ -1,4 +1,4 @@ -import {ChangeSet, RangeSet, Range, findClusterBreak, SelectionRange} from "@codemirror/state" +import {ChangeSet, RangeSet, findClusterBreak, SelectionRange} from "@codemirror/state" import {ContentView, ChildCursor, ViewFlag, DOMPos, replaceRange} from "./contentview" import {BlockView, LineView, BlockWidgetView} from "./blockview" import {TextView, MarkView} from "./inlineview" @@ -192,14 +192,7 @@ export class DocView extends ContentView { private updateEditContextFormatting(update: ViewUpdate) { this.editContextFormatting = this.editContextFormatting.map(update.changes) for (let tr of update.transactions) for (let effect of tr.effects) if (effect.is(setEditContextFormatting)) { - this.editContextFormatting = Decoration.set(effect.value.map(format => { - let lineStyle = format.underlineStyle, thickness = format.underlineThickness - if (lineStyle == "None" || thickness == "None") return null - let style = `text-decoration: underline ${ - lineStyle == "Dashed" ? "dashed " : lineStyle == "Squiggle" ? "wavy " : "" - }${thickness == "Thin" ? 1 : 2}px` - return Decoration.mark({attributes: {style}}).range(format.rangeStart, format.rangeEnd) - }).filter(x => x) as Range[]) + this.editContextFormatting = effect.value } } diff --git a/src/domobserver.ts b/src/domobserver.ts index da20edee..265588c7 100644 --- a/src/domobserver.ts +++ b/src/domobserver.ts @@ -5,8 +5,9 @@ import {editable, ViewUpdate, setEditContextFormatting, MeasureRequest} from "./ import {hasSelection, getSelection, DOMSelectionState, isEquivalentPosition, deepActiveElement, dispatchKey, atElementStart} from "./dom" import {DOMChange, applyDOMChange, applyDOMChangeInner} from "./domchange" -import type {EditContext, TextUpdateEvent} from "./editcontext" -import {Text, EditorSelection} from "@codemirror/state" +import type {EditContext} from "./editcontext" +import {Decoration} from "./decoration" +import {Text, EditorSelection, EditorState} from "@codemirror/state" const observeOptions = { childList: true, @@ -27,8 +28,7 @@ export class DOMObserver { observer: MutationObserver active: boolean = false - editContext: EditContext | null = null - editContextMeasure: MeasureRequest | null = null + editContext: EditContextManager | null = null // The known selection. Kept in our own object, as opposed to just // directly accessing the selection because: @@ -81,8 +81,10 @@ export class DOMObserver { this.flush() }) - if (window.EditContext) - view.contentDOM.editContext = this.editContext = this.createEditContext() + if (window.EditContext) { + this.editContext = new EditContextManager(view) + view.contentDOM.editContext = this.editContext.editContext + } if (useCharData) this.onCharData = (event: MutationEvent) => { @@ -127,41 +129,6 @@ export class DOMObserver { this.readSelectionRange() } - createEditContext() { - let {view} = this - let context = new window.EditContext({ - text: view.state.doc.toString(), // FIXME window? - selectionStart: view.state.selection.main.anchor, - selectionEnd: view.state.selection.main.head - }) - context.addEventListener("textupdate", e => this.onTextUpdate(e)) - context.addEventListener("characterboundsupdate", e => { - let rects: DOMRect[] = [], prev: DOMRect | null = null - for (let i = e.rangeStart; i < e.rangeEnd; i++) { - let rect = view.coordsForChar(i) - prev = (rect && new DOMRect(rect.left, rect.right, rect.right - rect.left, rect.bottom - rect.top)) - || prev || new DOMRect - rects.push(prev) - } - context.updateCharacterBounds(e.rangeStart, rects) - }) - context.addEventListener("textformatupdate", e => { - this.view.dispatch({effects: setEditContextFormatting.of(e.getTextFormats())}) - }) - context.addEventListener("compositionstart", () => { - if (view.inputState.composing < 0) view.inputState.composing = 0 - }) - context.addEventListener("compositionend", () => view.inputState.composing = -1) - - this.editContextMeasure = {read: view => { - this.editContext!.updateControlBounds(view.contentDOM.getBoundingClientRect()) - let sel = getSelection(view.root) - if (sel && sel.rangeCount) - this.editContext!.updateSelectionBounds(sel.getRangeAt(0).getBoundingClientRect()) - }} - return context - } - onScrollChanged(e: Event) { this.view.inputState.runHandlers("scroll", e) if (this.intersecting) this.view.measure() @@ -169,7 +136,7 @@ export class DOMObserver { onScroll(e: Event) { if (this.intersecting) this.flush(false) - if (this.editContext) this.view.requestMeasure(this.editContextMeasure!) + if (this.editContext) this.view.requestMeasure(this.editContext.measureReq) this.onScrollChanged(e) } @@ -282,16 +249,6 @@ export class DOMObserver { } } - onTextUpdate(e: TextUpdateEvent) { - let change = {from: e.updateRangeStart, to: e.updateRangeEnd, insert: Text.of(e.text.split("\n"))} - // Undo the change in the edit context right away so that we can - // safely apply our transaction (which may make a different - // change, more changes, or no changes at all) to it later. - this.editContext!.updateText(change.from, change.from + change.insert.length, - this.view.state.doc.sliceString(change.from, change.to)) - applyDOMChangeInner(this.view, change, EditorSelection.single(e.selectionStart, e.selectionEnd)) - } - ignore(f: () => T): T { if (!this.active) return f() try { @@ -481,19 +438,7 @@ export class DOMObserver { } update(update: ViewUpdate) { - if (this.editContext) { - for (let tr of update.transactions) { - tr.changes.iterChanges((fromA, toA, fromB, toB, insert) => { - this.editContext!.updateText(fromA, toA, insert.toString()) - }) - } - if (update.docChanged || update.selectionSet) { - let {main} = update.state.selection - this.editContext.updateSelection(main.anchor, main.head) - } - } - if (this.editContext && (update.geometryChanged || update.docChanged || update.selectionSet)) - this.view.requestMeasure(this.editContextMeasure!) + if (this.editContext) this.editContext.update(update) } destroy() { @@ -555,3 +500,136 @@ function safariSelectionRangeHack(view: EditorView, selection: Selection) { view.contentDOM.removeEventListener("beforeinput", read, true) return found ? buildSelectionRangeFromRange(view, found) : null } + +const enum CxVp { + Margin = 10000, + MaxSize = Margin * 3, + MinMargin = 500 +} + +class EditContextManager { + editContext: EditContext + measureReq: MeasureRequest + // The document window for which the text in the context is + // maintained. For large documents, this may be smaller than the + // editor document. This window always includes the selection head. + from: number = 0 + to: number = 0 + + constructor(view: EditorView) { + this.resetRange(view.state) + + let context = this.editContext = new window.EditContext({ + text: view.state.doc.sliceString(this.from, this.to), + selectionStart: this.toContextPos(Math.max(this.from, Math.min(this.to, view.state.selection.main.anchor))), + selectionEnd: this.toContextPos(view.state.selection.main.head) + }) + context.addEventListener("textupdate", e => { + let {anchor} = view.state.selection.main + let change = {from: this.toEditorPos(e.updateRangeStart), + to: this.toEditorPos(e.updateRangeEnd), + insert: Text.of(e.text.split("\n"))} + // If the window doesn't include the anchor, assume changes + // adjacent to a side go up to the anchor. + if (change.from == this.from && anchor < this.from) change.from = anchor + else if (change.to == this.to && anchor > this.to) change.to = anchor + // Undo the change in the edit context right away so that we can + // safely apply our transaction (which may make a different + // change, more changes, or no changes at all) to it later. + this.editContext.updateText(e.updateRangeStart, e.updateRangeStart + change.insert.length, + view.state.doc.sliceString(change.from, change.to)) + applyDOMChangeInner(view, change, EditorSelection.single(this.toEditorPos(e.selectionStart), + this.toEditorPos(e.selectionEnd))) + }) + context.addEventListener("characterboundsupdate", e => { + let rects: DOMRect[] = [], prev: DOMRect | null = null + for (let i = this.toEditorPos(e.rangeStart), end = this.toEditorPos(e.rangeEnd); i < end; i++) { + let rect = view.coordsForChar(i) + prev = (rect && new DOMRect(rect.left, rect.right, rect.right - rect.left, rect.bottom - rect.top)) + || prev || new DOMRect + rects.push(prev) + } + context.updateCharacterBounds(e.rangeStart, rects) + }) + context.addEventListener("textformatupdate", e => { + let deco = [] + for (let format of e.getTextFormats()) { + let lineStyle = format.underlineStyle, thickness = format.underlineThickness + if (lineStyle != "None" && thickness != "None") { + let style = `text-decoration: underline ${ + lineStyle == "Dashed" ? "dashed " : lineStyle == "Squiggle" ? "wavy " : "" + }${thickness == "Thin" ? 1 : 2}px` + deco.push(Decoration.mark({attributes: {style}}) + .range(this.toEditorPos(format.rangeStart), this.toEditorPos(format.rangeEnd))) + } + } + view.dispatch({effects: setEditContextFormatting.of(Decoration.set(deco))}) + }) + context.addEventListener("compositionstart", () => { + if (view.inputState.composing < 0) view.inputState.composing = 0 + }) + context.addEventListener("compositionend", () => view.inputState.composing = -1) + + this.measureReq = {read: view => { + this.editContext!.updateControlBounds(view.contentDOM.getBoundingClientRect()) + let sel = getSelection(view.root) + if (sel && sel.rangeCount) + this.editContext!.updateSelectionBounds(sel.getRangeAt(0).getBoundingClientRect()) + }} + } + + applyEdits(update: ViewUpdate) { + let off = 0, abort = false + update.changes.iterChanges((fromA, toA, _fromB, _toB, insert) => { + if (abort) return + let dLen = insert.length - (toA - fromA) + fromA += off; toA += off + if (toA <= this.from) { // Before the window + this.from += dLen; this.to += dLen + } else if (fromA < this.to) { // Overlaps with window + if (fromA < this.from || toA > this.to || (this.to - this.from) + insert.length > CxVp.MaxSize) { + abort = true + return + } + this.editContext!.updateText(this.toContextPos(fromA), this.toContextPos(toA), insert.toString()) + this.to += dLen + } + off += dLen + }) + return !abort + } + + update(update: ViewUpdate) { + if (!this.applyEdits(update) || !this.rangeIsValid(update.state)) { + this.resetRange(update.state) + this.editContext.updateText(0, this.editContext.text.length, update.state.doc.sliceString(this.from, this.to)) + this.setSelection(update.state) + } else if (update.docChanged || update.selectionSet) { + this.setSelection(update.state) + } + if (update.geometryChanged || update.docChanged || update.selectionSet) + update.view.requestMeasure(this.measureReq) + } + + resetRange(state: EditorState) { + let {head} = state.selection.main + this.from = Math.max(0, head - CxVp.Margin) + this.to = Math.min(state.doc.length, head + CxVp.Margin) + } + + setSelection(state: EditorState) { + let {main} = state.selection + this.editContext.updateSelection(this.toContextPos(Math.max(this.from, Math.min(this.to, main.anchor))), + this.toContextPos(main.head)) + } + + rangeIsValid(state: EditorState) { + let {head} = state.selection.main + return !(this.from > 0 && head - this.from < CxVp.MinMargin || + this.to < state.doc.length && this.to - head < CxVp.MinMargin || + this.to - this.from > CxVp.Margin * 3) + } + + toEditorPos(contextPos: number) { return contextPos + this.from } + toContextPos(editorPos: number) { return editorPos - this.from } +} diff --git a/src/extension.ts b/src/extension.ts index d13bcfbe..bad23a73 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,7 +7,6 @@ import {Attrs} from "./attributes" import {Isolate, autoDirection} from "./bidi" import {Rect, ScrollStrategy} from "./dom" import {MakeSelectionStyle} from "./input" -import {TextFormat} from "./editcontext" /// Command functions are used in key bindings and other types of user /// actions. Given an editor view, they check whether their effect can @@ -74,7 +73,7 @@ export class ScrollTarget { export const scrollIntoView = StateEffect.define({map: (t, ch) => t.map(ch)}) -export const setEditContextFormatting = StateEffect.define() +export const setEditContextFormatting = StateEffect.define() /// Log or report an unhandled exception in client code. Should /// probably only be used by extension code that allows client code to From ae1c5515d8847dd714355fc04a8296a6c636a0fd Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 19 Apr 2024 12:58:03 +0200 Subject: [PATCH 3/6] Avoid resetting edit context when the change made to editor state matches the context --- src/domobserver.ts | 49 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/domobserver.ts b/src/domobserver.ts index 265588c7..28960e60 100644 --- a/src/domobserver.ts +++ b/src/domobserver.ts @@ -515,6 +515,12 @@ class EditContextManager { // editor document. This window always includes the selection head. from: number = 0 to: number = 0 + // When applying a transaction, this is used to compare the change + // made to the context content to the change in the transaction in + // order to make the minimal changes to the context (since touching + // that sometimes breaks series of multiple edits made for a single + // user action on some Android keyboards) + pendingContextChange: {from: number, to: number, insert: Text} | null = null constructor(view: EditorView) { this.resetRange(view.state) @@ -533,13 +539,16 @@ class EditContextManager { // adjacent to a side go up to the anchor. if (change.from == this.from && anchor < this.from) change.from = anchor else if (change.to == this.to && anchor > this.to) change.to = anchor - // Undo the change in the edit context right away so that we can - // safely apply our transaction (which may make a different - // change, more changes, or no changes at all) to it later. - this.editContext.updateText(e.updateRangeStart, e.updateRangeStart + change.insert.length, - view.state.doc.sliceString(change.from, change.to)) + + // Edit context sometimes fire empty changes + if (change.from == change.to && !change.insert.length) return + + this.pendingContextChange = change applyDOMChangeInner(view, change, EditorSelection.single(this.toEditorPos(e.selectionStart), this.toEditorPos(e.selectionEnd))) + // If the transaction didn't flush our change, revert it so + // that the context is in sync with the editor state again. + if (this.pendingContextChange) this.revertPending(view.state) }) context.addEventListener("characterboundsupdate", e => { let rects: DOMRect[] = [], prev: DOMRect | null = null @@ -579,10 +588,22 @@ class EditContextManager { } applyEdits(update: ViewUpdate) { - let off = 0, abort = false + let off = 0, abort = false, pending = this.pendingContextChange update.changes.iterChanges((fromA, toA, _fromB, _toB, insert) => { if (abort) return + let dLen = insert.length - (toA - fromA) + if (pending && toA >= pending.to) { + if (pending.from == fromA && pending.to == toA && pending.insert.eq(insert)) { + pending = this.pendingContextChange = null // Match + off += dLen + return + } else { // Mismatch, revert + pending = null + this.revertPending(update.state) + } + } + fromA += off; toA += off if (toA <= this.from) { // Before the window this.from += dLen; this.to += dLen @@ -596,11 +617,13 @@ class EditContextManager { } off += dLen }) + if (pending && !abort) this.revertPending(update.state) return !abort } update(update: ViewUpdate) { if (!this.applyEdits(update) || !this.rangeIsValid(update.state)) { + this.pendingContextChange = null this.resetRange(update.state) this.editContext.updateText(0, this.editContext.text.length, update.state.doc.sliceString(this.from, this.to)) this.setSelection(update.state) @@ -617,10 +640,20 @@ class EditContextManager { this.to = Math.min(state.doc.length, head + CxVp.Margin) } + revertPending(state: EditorState) { + let pending = this.pendingContextChange! + this.pendingContextChange = null + this.editContext.updateText(this.toContextPos(pending.from), + this.toContextPos(pending.to + pending.insert.length), + state.doc.sliceString(pending.from, pending.to)) + } + setSelection(state: EditorState) { let {main} = state.selection - this.editContext.updateSelection(this.toContextPos(Math.max(this.from, Math.min(this.to, main.anchor))), - this.toContextPos(main.head)) + let start = this.toContextPos(Math.max(this.from, Math.min(this.to, main.anchor))) + let end = this.toContextPos(main.head) + if (this.editContext.selectionStart != start || this.editContext.selectionEnd != end) + this.editContext.updateSelection(start, end) } rangeIsValid(state: EditorState) { From df2cb96ff5feab9fd988785d6157a54e45ab131b Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 19 Apr 2024 13:24:12 +0200 Subject: [PATCH 4/6] Properly track first composition change for edit context changes --- src/domchange.ts | 2 +- src/domobserver.ts | 10 ++++++++-- src/input.ts | 1 - 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/domchange.ts b/src/domchange.ts index f862a051..07d3364b 100644 --- a/src/domchange.ts +++ b/src/domchange.ts @@ -220,7 +220,7 @@ function applyDefaultInsert(view: EditorView, change: {from: number, to: number, } let userEvent = "input.type" if (view.composing || - view.inputState.compositionPendingChange && view.inputState.compositionEndedAt > Date.now() - 50) { + view.inputState.compositionPendingChange && view.inputState.compositionEndedAt > Date.now() - 50) { view.inputState.compositionPendingChange = false userEvent += ".compose" if (view.inputState.compositionFirstChange) { diff --git a/src/domobserver.ts b/src/domobserver.ts index 28960e60..e4a38884 100644 --- a/src/domobserver.ts +++ b/src/domobserver.ts @@ -575,9 +575,15 @@ class EditContextManager { view.dispatch({effects: setEditContextFormatting.of(Decoration.set(deco))}) }) context.addEventListener("compositionstart", () => { - if (view.inputState.composing < 0) view.inputState.composing = 0 + if (view.inputState.composing < 0) { + view.inputState.composing = 0 + view.inputState.compositionFirstChange = true + } + }) + context.addEventListener("compositionend", () => { + view.inputState.composing = -1 + view.inputState.compositionFirstChange = null }) - context.addEventListener("compositionend", () => view.inputState.composing = -1) this.measureReq = {read: view => { this.editContext!.updateControlBounds(view.contentDOM.getBoundingClientRect()) diff --git a/src/input.ts b/src/input.ts index b77b3173..016d61f3 100644 --- a/src/input.ts +++ b/src/input.ts @@ -820,7 +820,6 @@ observers.compositionstart = observers.compositionupdate = view => { observers.compositionend = view => { if (view.observer.editContext) return // Composition handled by edit context - // FIXME check if any of these hacks are needed with edit context view.inputState.composing = -1 view.inputState.compositionEndedAt = Date.now() view.inputState.compositionPendingKey = true From 571426bdcb303b5075e5b08d9e1458243c156043 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Fri, 19 Apr 2024 14:27:18 +0200 Subject: [PATCH 5/6] Remove some superfluous exclamation marks --- src/domobserver.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domobserver.ts b/src/domobserver.ts index e4a38884..d96776bb 100644 --- a/src/domobserver.ts +++ b/src/domobserver.ts @@ -586,10 +586,10 @@ class EditContextManager { }) this.measureReq = {read: view => { - this.editContext!.updateControlBounds(view.contentDOM.getBoundingClientRect()) + this.editContext.updateControlBounds(view.contentDOM.getBoundingClientRect()) let sel = getSelection(view.root) if (sel && sel.rangeCount) - this.editContext!.updateSelectionBounds(sel.getRangeAt(0).getBoundingClientRect()) + this.editContext.updateSelectionBounds(sel.getRangeAt(0).getBoundingClientRect()) }} } @@ -618,7 +618,7 @@ class EditContextManager { abort = true return } - this.editContext!.updateText(this.toContextPos(fromA), this.toContextPos(toA), insert.toString()) + this.editContext.updateText(this.toContextPos(fromA), this.toContextPos(toA), insert.toString()) this.to += dLen } off += dLen From d1d926f6457e7c93797f6f3c70f36d995f5b64c2 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Sun, 21 Apr 2024 18:18:09 +0200 Subject: [PATCH 6/6] Provide an EditorView.EDIT_CONTEXT switch that turns off use of EditContext --- src/domobserver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domobserver.ts b/src/domobserver.ts index d96776bb..89407a0f 100644 --- a/src/domobserver.ts +++ b/src/domobserver.ts @@ -81,7 +81,7 @@ export class DOMObserver { this.flush() }) - if (window.EditContext) { + if (window.EditContext && (view.constructor as any).EDIT_CONTEXT !== false) { this.editContext = new EditContextManager(view) view.contentDOM.editContext = this.editContext.editContext }