Repository navigation
Tell QTI answers apart by their formulas and images - #6302
Conversation
0837154 to
c908560
Compare
AlexVelezLl
left a comment
There was a problem hiding this comment.
Listing all block elements like that is a smell; we could probably do better using ProseMirror's capabilities!
| if (!html.includes('<')) return html.trim(); | ||
| const body = QTISanitizer.textBody(html); | ||
| const text = body.textContent.trim(); | ||
| if (!text) return normaliseMarkup(html); |
There was a problem hiding this comment.
suggestion: mediaToken reads an image the way TipTap stores it, so a re-rendered image keys the same as the stored one. That only applies beside text. With no text, the raw markup is still the key, and TipTap changes it on every image I tried. Running the spec's own rewrap helper:
| Stored | After TipTap | Same key? |
|---|---|---|
<img src="a.png"/> |
<img style="text-align: left;" src="a.png"> |
no |
<p><img alt="a cat" src="a.png"/></p> |
<p></p><img style="text-align: left;" src="a.png" alt="a cat"> |
no |
<p><img src="a.png" title="t"/></p> |
<p></p><img style="text-align: left;" src="a.png"> |
no |
So when a stored image-only option sits beside the same image newly inserted in TipTap, the duplicate isn't flagged, and match/associate won't share them as one choice. This behaviour predates the PR. But the PR now pins in two rules that disagree: an image with a title is the same image beside text (line 113) and a different one alone (line 125).
Could a fragment with no text key on its media tokens, i.e. the same querySelectorAll(EMBEDDED_MEDIA) pass joined without the text? Then a media-only fragment uses the same identity rule, and the rewrap table could include image-only cases.
There was a problem hiding this comment.
Fixed in d1d64fe: an image-only fragment now keys on its Image node attrs, the same as beside text.
- The
rewraptable now has your three image-only cases, and they key the same. - The image identity tests run both beside text and alone (
describe.each), sotitle,class, size style andpermanentsrcfollow one rule in both. - Searched
QTIEditorfor other duplicate checks on raw content: none. Every duplicate check goes through this key.HintsSectioncompareshint.content ===only to skip a no-op update.
| * Elements whose surrounding whitespace is layout, not a word gap: TipTap drops it, and | ||
| * markup read back out of stored XML arrives pretty-printed with it. | ||
| */ | ||
| const BLOCK_ELEMENTS = |
There was a problem hiding this comment.
suggestion: BLOCK_ELEMENTS, the whitespace regexes, mediaToken's image attribute list and the NUL delimiters all copy by hand how TipTap parses content. The "after TipTap rewraps it" tests check that the copy matches today, but nothing keeps it in sync when the editor gains a node or an attribute.
Could the key come from TipTap's schema instead? Parse the fragment with ProseMirror's DOMParser and walk the doc:
import { getSchema } from '@tiptap/core';
import { DOMParser as PMDOMParser } from '@tiptap/pm/model';
const parser = PMDOMParser.fromSchema(getSchema(EDITOR_EXTENSIONS)); // once, at import
export function richTextComparisonKey(html) {
const { body } = new DOMParser().parseFromString(html ?? '', 'text/html');
const parts = [];
parser.parse(body).descendants(node => {
if (node.isText) parts.push(node.text);
else if (node.type.name === 'math') parts.push(['latex', node.attrs.latex]);
else if (node.type.name === 'image') parts.push(['img', node.attrs]);
});
return JSON.stringify(parts);
}- The schema already knows what's a block and keeps code-block whitespace, so
BLOCK_ELEMENTS,dropBlockIndentationand the whitespace regexes go away. node.attrsis exactly what the Image node keeps, so there's no hand-picked attribute list.- The key is structured JSON, so typed text can't pass for a formula and the NUL trick isn't needed.
- It matches TipTap's re-render because it runs the same parser. That also covers the image-only case in my other comment, so
normaliseMarkupcan go too. - No editor instance is created. The schema and parser are built once. Each call is one DOM parse, which the current code already does, plus two linear walks. Don't use
generateJSONhere: it rebuilds the schema on every call.
Since the key depends on the editor's schema, I think it belongs in TipTapEditor/TipTapEditor/utils/, with the extension list exported from useEditor.js so the editor and the key share one source. It should use the full schema, not the inline-only one, so inline and block content of the same text key the same. The QTI call sites only change their import. TipTap can't import from QTI, so it parses the fragment itself. ProseMirror's parser already skips script and style, so the new QTISanitizer.textBody isn't needed and that change can be reverted.
One behaviour change: ProseMirror collapses runs of spaces, so a b and a b would become duplicates. They render the same to a learner, so I'd count that as a fix, but it reverses the "keeps runs of whitespace in text alone" test.
There was a problem hiding this comment.
Done in d1d64fe, as you sketched:
richTextComparisonKeymoved toTipTapEditor/TipTapEditor/utils/comparisonKey.js. It parses with the full schema fromeditorExtensions(), now exported fromuseEditor.js, which builds its own editors from it.BLOCK_ELEMENTS, the whitespace regexes,normaliseMarkup, the NUL delimiters andQTISanitizer.textBodyare gone. The QTI call sites only change their import.- Images key on
node.attrsminuspermanentSrc. It records where an upload is saved, and keying on it would split a new upload from the same image once saved. - One parse was not enough:
<p>see <img> here</p>keeps the space beforehere, but TipTap drops it when it reads its own output. The key serializes the first parse and parses again, so it reads what TipTap saves. - Adjacent text nodes are joined, so
<strong>Par</strong>isstill keys asParis. Non-breaking spaces at the edges are still trimmed. - The "keeps runs of whitespace" test is now "collapses runs of whitespace in text".
- Two associate validation tests asserted the literal key
"Lysander". They now compare againstrichTextComparisonKey(...), as the match tests already did. - MathML and inline SVG are not in the schema, so they now key as their text, which is what TipTap shows and saves.
There was a problem hiding this comment.
194ea5a: MathML and inline SVG now key on their markup where they stand, not as their text; sub/superscript text keys apart from plain text, and a non-breaking space keys as a space.
d1d64fe to
00bef0e
Compare
|
Outside this PR's scope — tracked in:
Written by rtibblesbot, an LLM-based coding agent. |
0a1efd0 to
ab376e6
Compare
| // Not where an upload is saved, nor the size ImageNodeView writes when it loads. | ||
| parts.push(['img', omit(node.attrs, ['permanentSrc', 'width', 'height'])]); | ||
| lineStart = false; | ||
| } else if (node.isTextblock || node.type.name === 'hardBreak') { |
There was a problem hiding this comment.
blocking: Breaks add nothing to the key, so text on either side of them is joined. I checked: <p>12</p><p>3</p> and <p>1</p><p>23</p> both key to ["123"], and <p>a<br>b</p> keys the same as <p>ab</p>. buildMatchInteractionXML (match/parse.js:156-164) merges answers by this key. If row 1's answer is 12⏎3 and row 2's is 1⏎23, saving replaces row 2's answer with row 1's, and no validation error appears because Match allows a shared answer. That's the same silent replacement #6301 is fixing, just triggered by a break instead of a formula.
You can keep Shift+Enter and Enter equal to each other (as the PR description intends) by ending the current text and pushing a break marker such as ['br'] here, instead of only setting lineStart. Skip the marker at the very start and end, so trailing empty paragraphs still drop out. The reads text split over %s as the same text on one line test (comparisonKey.spec.js:121) would then expect a key that differs from <p>ab</p>.
There was a problem hiding this comment.
Fixed in c4c3f89: a paragraph or hard break now adds one ["br"], none at the start or end.
<p>12</p><p>3</p>and<p>1</p><p>23</p>key apart; Shift+Enter and Enter still key the same.- Runs of empty paragraphs between lines read as one break.
- Searched for merges by this key:
match/parse.jsandassociate/parse.js, both fixed by the key. Added your case to the Match round-trip test.
|
|
||
| it.each([ | ||
| ['alt text', '<img src="a.png" alt="a cat"/>', '<img src="a.png" alt="a dog"/>'], | ||
| ['alignment', '<img src="a.png"/>', '<img src="a.png" data-text-align="center"/>'], |
There was a problem hiding this comment.
suggestion: On the reviewer-guidance question: the key ignores bold and image size because a learner sees the same answer either way. Alignment works the same way: the same image centered or left-aligned is still the same answer to a learner, so keeping textAlign in the key seems inconsistent. I'd also leave it out, along with permanentSrc/width/height. alt is less clear-cut, since screen-reader users hear it, so keeping it seems fine.
There was a problem hiding this comment.
Done in c4c3f89: textAlign dropped, so an image keys on src and alt. permanentSrc/width/height were already left out.
| // The resolver does not read package `exports` subpaths. | ||
| // eslint-disable-next-line import/no-unresolved | ||
| import { DOMParser as PMDOMParser } from '@tiptap/pm/model'; | ||
| import { editorExtensions } from '../composables/useEditor'; |
There was a problem hiding this comment.
nitpick: Importing editorExtensions from the useEditor composable means headless validation (validateItem → descriptors → this module) also loads @tiptap/vue-2's Editor and the markdown serializer and paste transform, none of which building a schema needs. Moving editorExtensions (and INLINE_ONLY_STARTER_KIT) into its own module, e.g. TipTapEditor/extensions/index.js, would keep those out of the validation import graph. Image and Math would still bring in their node views, so this only narrows the imports, not removes TipTap.
There was a problem hiding this comment.
Done in 2dfc4a2: editorExtensions and INLINE_ONLY_STARTER_KIT now live in TipTapEditor/extensions/index.js. comparisonKey.js was the only importer outside the composable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ab376e6 to
c4c3f89
Compare
…lone - Answers differing only in a formula, image, SVG, MathML or sub/superscript no longer merge in match/associate or flag as duplicates. - The key parses with the editor's schema, so it reads content as TipTap renders it. - Whitespace at line edges keys as nothing; a non-breaking space inside text keys apart from a space. Closes learningequality#6301 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c4c3f89 to
b776b58
Compare
AlexVelezLl
left a comment
There was a problem hiding this comment.
Code changes look solid, tests provide good coverage, and manual QA checks out. LGTM!
|
Quick check on rtibblesbot's work on this PR: react 👍 helpful · 😕 mixed · 👎 not helpful. Reply with anything specific. |
Summary
References
Fixes #6301. Reported in: #6297 (review comment).
Reviewer guidance
Questions:
srcandalt, not size or alignment. Shouldaltcount, orsrcalone?x² solveandsolve x²differ, as now?Manual QA
QA team: answers now differ by formulas, images, scripts and line breaks. Match and Associate are most at risk, because a save can merge answers.
Requires: one image file.
Big cat, a formula and the image.12Enter3. Give row 41Enter23.TextEnter image, andTextEnter Enter image. Both show the error.xwith Superscript2,xwith Subscript2,x2, and boldx2. Onlyx2and boldx2show the error.a b,aShift+Enterb,aEnterb, andaEnter Enterb. Only the three multi-line options show the error. Is that right?12Enter3and1Enter23. Neither shows the error.Evidence
Multiple choice line breaks and images
12⏎3and1⏎23not flagged; every other pair flaggedMatch line breaks and aligned image
match-author-save-reopen.webm
Ordering
s2-ordering-duplicates.webm
Multiple choice images
s3-image-duplicates-resize-reopen.webm
Associate
s5-associate-duplicates-reopen.webm
More captures (29)
a bandabnot flagged; image option flaggedAI usage
Used Claude Code to write the fix and its tests from the issue. Verified with the QTIEditor and TipTapEditor Jest suites and pre-commit.
Committed separately: no commit on this branch introduced the lines these changes touch, so they are a new commit rather than folded into the work they amend:
contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey.jscontentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/comparisonKey.spec.jsDeviations from the issue spec
12⏎3and1⏎23apart (reviewer request).Written by rtibblesbot, an LLM-based coding agent.
🟡 Waiting for feedback
Last updated: 2026-10-14 22:03 UTC