Skip to content

Commit

Permalink
Prevent composed text from inheriting the style of non-inclusive mark…
Browse files Browse the repository at this point in the history
…s 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 codemirror/dev#1324
  • Loading branch information
marijnh committed Feb 20, 2024
1 parent ccf7f5e commit 4e355ea
Show file tree
Hide file tree
Showing 3 changed files with 49 additions and 11 deletions.
8 changes: 5 additions & 3 deletions src/buildview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@ export class ContentBuilder implements SpanIterator<Decoration> {
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)
Expand Down Expand Up @@ -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")
}
48 changes: 40 additions & 8 deletions src/docview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<ContentView> = 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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) {
Expand Down Expand Up @@ -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") &&
Expand Down
4 changes: 4 additions & 0 deletions src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}

Expand Down

0 comments on commit 4e355ea

Please sign in to comment.