From 2ef3670c124868bfe2cad39e78b77efb0a16c490 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Fri, 27 Mar 2026 02:30:33 +0000 Subject: [PATCH] fix: detect polarity changes in programmatic value updates --- lib/cql/src/cqlInput/CqlInput.spec.ts | 11 ++++++ lib/cql/src/cqlInput/editor/diff.ts | 39 ++++++++++++++++++++-- lib/cql/src/cqlInput/editor/editor.spec.ts | 11 ++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/lib/cql/src/cqlInput/CqlInput.spec.ts b/lib/cql/src/cqlInput/CqlInput.spec.ts index bd7781f6..cc529687 100644 --- a/lib/cql/src/cqlInput/CqlInput.spec.ts +++ b/lib/cql/src/cqlInput/CqlInput.spec.ts @@ -78,4 +78,15 @@ describe("CqlInput", () => { "This event should be propagated to the input's container, as it is not handled by the editor", ).toBe(true); }); + + it("should update the rendered value when only the polarity changes", () => { + const { cqlInput } = createCqlInputContainer("+tag:one"); + let callbackValue = ""; + cqlInput.addEventListener( + "queryChange", + (e) => (callbackValue = e.detail.queryStr), + ); + cqlInput.setAttribute("value", "-tag:one"); + expect(callbackValue).toBe("-tag:one"); + }); }); diff --git a/lib/cql/src/cqlInput/editor/diff.ts b/lib/cql/src/cqlInput/editor/diff.ts index 9913d562..a2a0b29b 100644 --- a/lib/cql/src/cqlInput/editor/diff.ts +++ b/lib/cql/src/cqlInput/editor/diff.ts @@ -1,10 +1,41 @@ -import { Fragment } from "prosemirror-model"; +import { Fragment, Mark, Node } from "prosemirror-model"; +import { IS_READ_ONLY, IS_SELECTED } from "./schema"; /** - * These diff functions are vendored to ignore attributes and markup, as we only care about markup. + * These diff functions are vendored from ProseMirror's Fragment.findDiffStart / + * findDiffEnd to customise attribute comparison. + * + * The originals use Node.sameMarkup, which compares ALL attributes. That + * causes false positives for transient editor-state attributes (IS_SELECTED, + * IS_READ_ONLY) that differ between the live document and an incoming document + * built by queryToProseMirrorDoc — see #52. + * + * We replace sameMarkup with sameContentMarkup, which skips transient attrs + * so the diff is blind to decorative state but still detects semantically + * meaningful changes like POLARITY. + * * Source: https://github.com/ProseMirror/prosemirror-model/blob/c8c7b62645d2a8293fa6b7f52aa2b04a97821f34/src/diff.ts */ +/** Attrs that are transient editor state, not query content. */ +const TRANSIENT_ATTRS: ReadonlySet = new Set([IS_SELECTED, IS_READ_ONLY]); + +/** + * Like Node.sameMarkup, but ignores transient editor-state attributes. + */ +function sameContentMarkup(a: Node, b: Node): boolean { + if (a.type !== b.type || !Mark.sameSet(a.marks, b.marks)) return false; + for (const key in a.attrs) { + if (TRANSIENT_ATTRS.has(key)) continue; + if (a.attrs[key] !== b.attrs[key]) return false; + } + for (const key in b.attrs) { + if (TRANSIENT_ATTRS.has(key)) continue; + if (!(key in a.attrs)) return false; + } + return true; +} + export function findDiffStartForContent( a: Fragment, b: Fragment, @@ -21,6 +52,8 @@ export function findDiffStartForContent( continue; } + if (!sameContentMarkup(childA, childB)) return pos; + if (childA.isText && childA.text != childB.text) { for (let j = 0; childA.text![j] == childB.text![j]; j++) pos++; return pos; @@ -51,6 +84,8 @@ export function findDiffEndForContent( continue; } + if (!sameContentMarkup(childA, childB)) return { a: posA, b: posB }; + if (childA.isText && childA.text != childB.text) { let same = 0; const minSize = Math.min(childA.text!.length, childB.text!.length); diff --git a/lib/cql/src/cqlInput/editor/editor.spec.ts b/lib/cql/src/cqlInput/editor/editor.spec.ts index 67a816f5..d6cc6c1b 100644 --- a/lib/cql/src/cqlInput/editor/editor.spec.ts +++ b/lib/cql/src/cqlInput/editor/editor.spec.ts @@ -140,4 +140,15 @@ describe("updateEditorViewWithQueryStr", () => { expect(docToCqlStrWithSelection(editorView.state)).toEqual("+tag:^$ "); }); + + it("should update the document when only the polarity of a chip changes", () => { + const { editorView, updateEditorView } = + createEditorFromInitialState("+tag:a"); + + expect(docToCqlStr(editorView.state.doc)).toEqual("+tag:a "); + + updateEditorView("-tag:a"); + + expect(docToCqlStr(editorView.state.doc)).toEqual("-tag:a "); + }); });