From 4e355eab50de94ab315ed293729f5365841fe3c8 Mon Sep 17 00:00:00 2001 From: Marijn Haverbeke Date: Tue, 20 Feb 2024 15:19:44 +0100 Subject: [PATCH] Prevent composed text from inheriting the style of non-inclusive marks before FIX: When starting a composition after a non-inclusive mark decoration, temporarily insert a widget that prevents the composed text from inheriting that mark's styles. Issue https://github.com/codemirror/dev/issues/1324 --- src/buildview.ts | 8 +++++--- src/docview.ts | 48 ++++++++++++++++++++++++++++++++++++++++-------- src/input.ts | 4 ++++ 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/buildview.ts b/src/buildview.ts index 5df49937..671898f6 100644 --- a/src/buildview.ts +++ b/src/buildview.ts @@ -111,9 +111,9 @@ export class ContentBuilder implements SpanIterator { if (deco instanceof PointDecoration) { if (deco.block) { if (deco.startSide > 0 && !this.posCovered()) this.getLine() - this.addBlockWidget(new BlockWidgetView(deco.widget || new NullWidget("div"), len, deco)) + this.addBlockWidget(new BlockWidgetView(deco.widget || NullWidget.block, len, deco)) } else { - let view = WidgetView.create(deco.widget || new NullWidget("span"), len, len ? 0 : deco.startSide) + let view = WidgetView.create(deco.widget || NullWidget.inline, len, len ? 0 : deco.startSide) let cursorBefore = this.atCursorPos && !view.isEditable && openStart <= active.length && (from < to || deco.startSide > 0) let cursorAfter = !view.isEditable && (from < to || openStart > active.length || deco.startSide <= 0) @@ -162,10 +162,12 @@ function wrapMarks(view: ContentView, active: readonly MarkDecoration[]) { return view } -class NullWidget extends WidgetType { +export class NullWidget extends WidgetType { constructor(readonly tag: string) { super() } eq(other: NullWidget) { return other.tag == this.tag } toDOM() { return document.createElement(this.tag) } updateDOM(elt: HTMLElement) { return elt.nodeName.toLowerCase() == this.tag } get isHidden() { return true } + static inline = new NullWidget("span") + static block = new NullWidget("div") } diff --git a/src/docview.ts b/src/docview.ts index 12a1b615..8b48107a 100644 --- a/src/docview.ts +++ b/src/docview.ts @@ -2,7 +2,7 @@ import {ChangeSet, RangeSet, findClusterBreak, SelectionRange} from "@codemirror import {ContentView, ChildCursor, ViewFlag, DOMPos, replaceRange} from "./contentview" import {BlockView, LineView, BlockWidgetView} from "./blockview" import {TextView, MarkView} from "./inlineview" -import {ContentBuilder} from "./buildview" +import {ContentBuilder, NullWidget} from "./buildview" import browser from "./browser" import {Decoration, DecorationSet, WidgetType, addRange, MarkDecoration} from "./decoration" import {getAttrs} from "./attributes" @@ -24,10 +24,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 + compositionBarrier = Decoration.none // Track a minimum width for the editor. When measuring sizes in // measureVisibleLineHeights, this is updated to point at the width @@ -301,7 +302,7 @@ export class DocView extends ContentView { // composition, avoid moving it across it and disrupting the // composition. suppressWidgetCursorChange(sel: DOMSelectionState, cursor: SelectionRange) { - return this.hasComposition && cursor.empty && + return this.hasComposition && cursor.empty && !this.compositionBarrier.size && isEquivalentPosition(sel.focusNode!, sel.focusOffset, sel.anchorNode, sel.anchorOffset) && this.posFromDOM(sel.focusNode!, sel.focusOffset) == cursor.head } @@ -499,8 +500,9 @@ export class DocView extends ContentView { } updateDeco() { - let allDeco = this.view.state.facet(decorationsFacet).map((d, i) => { - let dynamic = this.dynamicDecorationMap[i] = typeof d == "function" + 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 }) let dynamicOuter = false, outerDeco = this.view.state.facet(outerDecorations).map((d, i) => { @@ -509,15 +511,43 @@ export class DocView extends ContentView { return dynamic ? (d as (view: EditorView) => DecorationSet)(this.view) : d as DecorationSet }) if (outerDeco.length) { - this.dynamicDecorationMap[allDeco.length] = dynamicOuter + this.dynamicDecorationMap[i++] = dynamicOuter allDeco.push(RangeSet.join(outerDeco)) } - for (let i = allDeco.length; i < allDeco.length + 3; i++) this.dynamicDecorationMap[i] = false - return this.decorations = [ + this.decorations = [ + this.compositionBarrier, ...allDeco, this.computeBlockGapDeco(), this.view.viewState.lineGapDeco ] + while (i < this.decorations.length) this.dynamicDecorationMap[i++] = false + return this.decorations + } + + // Starting a composition will style the inserted text with the + // style of the text before it, and this is only cleared when the + // composition ends, because touching it before that will abort it. + // This (called from compositionstart handler) tries to notice when + // the cursor is after a non-inclusive mark, where the styling could + // be jarring, and insert an ad-hoc widget before the cursor to + // isolate it from the style before it. + maybeCreateCompositionBarrier() { + let {main: {head, empty}} = this.view.state.selection + if (!empty) return false + let found: boolean | null = null + for (let set of this.decorations) { + set.between(head, head, (from, to, value) => { + if (value.point) found = false + else if (value.endSide < 0 && from < head && to == head) found = true + }) + if (found === false) break + } + this.compositionBarrier = found ? Decoration.set(compositionBarrierWidget.range(head)) : Decoration.none + return !!found + } + + clearCompositionBarrier() { + this.compositionBarrier = Decoration.none } scrollIntoView(target: ScrollTarget) { @@ -552,6 +582,8 @@ export class DocView extends ContentView { split!: () => ContentView } +const compositionBarrierWidget = Decoration.widget({side: -1, widget: NullWidget.inline}) + function betweenUneditable(pos: DOMPos) { return pos.node.nodeType == 1 && pos.node.firstChild && (pos.offset == 0 || (pos.node.childNodes[pos.offset - 1] as HTMLElement).contentEditable == "false") && diff --git a/src/input.ts b/src/input.ts index 4f707c46..c30d19c5 100644 --- a/src/input.ts +++ b/src/input.ts @@ -811,6 +811,10 @@ observers.compositionstart = observers.compositionupdate = view => { if (view.inputState.composing < 0) { // FIXME possibly set a timeout to clear it again on Android view.inputState.composing = 0 + if (view.docView.maybeCreateCompositionBarrier()) { + view.update([]) + view.docView.clearCompositionBarrier() + } } }