diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue index 2153493a89..3621797039 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ShuffledResponsePool/index.vue @@ -43,8 +43,8 @@ import isEqual from 'lodash/isEqual'; import shuffle from 'lodash/shuffle'; import { themeTokens, themePalette } from 'kolibri-design-system/lib/styles/theme'; - import { richTextComparisonKey } from '../../utils/richText'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor'; + import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService'; export default { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue index db539c994c..174e989a49 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue @@ -248,8 +248,9 @@ import ClickableRegion from '../../components/ClickableRegion/index.vue'; import ShuffledResponsePool from '../../components/ShuffledResponsePool/index.vue'; import EditableChipList from '../../components/EditableChipList/index.vue'; - import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; + import { hasRichTextContent } from '../../utils/richText'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor'; + import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService'; /** Which card currently holds the open TipTap editor. */ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/parse.spec.js index c32e3ab262..78fa53bba0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/parse.spec.js @@ -13,6 +13,9 @@ const contentsOf = pairs => pairs.map(pair => pair.map(choice => choice.content) const SCHEMA = { baseType: BaseType.PAIR, cardinality: Cardinality.MULTIPLE }; +const SOLVE_X2 = '

Solve for x

'; +const SOLVE_Y3 = '

Solve for x

'; + const parseXmlString = xml => parseXML(xml).documentElement; describe('parse()', () => { @@ -430,6 +433,21 @@ describe('parse → buildXML → parse round-trip', () => { ]); }); + it('preserves two choices with the same text but different formulas', () => { + const state = { + responseIdentifier: 'RESPONSE', + prompt: '', + pairs: [ + [ + { id: 'choice_aaa11111', content: SOLVE_X2 }, + { id: 'choice_bbb22222', content: SOLVE_Y3 }, + ], + ], + distractors: [], + }; + expect(contentsOf(roundTrip(state).pairs)).toEqual([[SOLVE_X2, SOLVE_Y3]]); + }); + it('preserves the default state as one pair of two blank choices', () => { const reparsed = roundTrip(parse('', [])); expect(contentsOf(reparsed.pairs)).toEqual([['', '']]); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validation.spec.js index 433b0639a5..f14264cdd9 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validation.spec.js @@ -1,5 +1,6 @@ import { validateAssociateInteraction } from '../validation'; import { ValidationError } from '../../../constants'; +import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; function makeState(overrides = {}) { return { @@ -136,7 +137,7 @@ describe('validateAssociateInteraction()', () => { const errors = validateAssociateInteraction(state); expect(errors).toContainEqual({ code: ValidationError.DUPLICATE_DISTRACTOR_CONTENT, - text: 'Lysander', + text: richTextComparisonKey('Lysander'), }); expect( errors.filter(e => e.code === ValidationError.DUPLICATE_DISTRACTOR_CONTENT), @@ -147,7 +148,7 @@ describe('validateAssociateInteraction()', () => { const state = makeState({ distractors: [{ id: 'choice_eee55555', content: 'Antonio' }] }); expect(validateAssociateInteraction(state)).toContainEqual({ code: ValidationError.DUPLICATE_DISTRACTOR_CONTENT, - text: 'Antonio', + text: richTextComparisonKey('Antonio'), }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js index 908db6eb14..21d782ea8b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js @@ -4,8 +4,9 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; -import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; +import { hasRichTextContent } from '../../utils/richText'; import { RESPONSE_IDENTIFIER } from '../../constants'; +import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; const serializer = new XMLSerializer(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/validation.js index 7a728d9cf4..d6628e9f94 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/validation.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/validation.js @@ -1,6 +1,7 @@ import flatten from 'lodash/flatten'; import { ValidationError } from '../../constants'; -import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; +import { hasRichTextContent } from '../../utils/richText'; +import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; /** * Validate AssociateState → ValidationError[]. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js index 9f6e8224e2..23d9d2a868 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js @@ -132,6 +132,20 @@ describe('validate()', () => { ValidationError.DUPLICATE_CHOICE_CONTENT, ); }); + + it.each([ + ['a formula', '', ''], + ['an image', '', ''], + ['a script', '2', '2'], + ])('does not flag choices with the same text but %s that differs', (_, first, second) => { + const state = makeState({ + choices: [ + makeAnswer({ id: 'a', content: `

Solve ${first} for x

`, correct: true }), + makeAnswer({ id: 'b', content: `

Solve ${second} for x

`, correct: false }), + ], + }); + expect(validate(state, QuestionType.SINGLE_SELECT)).toEqual([]); + }); }); describe('EMPTY_CHOICE_CONTENT', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js index 91a09a21c0..e95154b237 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js @@ -1,5 +1,6 @@ import { QuestionType, ValidationError } from '../../constants'; -import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; +import { hasRichTextContent } from '../../utils/richText'; +import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; /** * Validate ChoiceState → ValidationError[]. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js index 395196005d..b7aa3360d9 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js @@ -9,8 +9,9 @@ import { inlineChoiceInteractionDescriptor } from './inlineChoice/Descriptor'; /** * Every interaction's descriptor: matching, parsing, building and validating XML. * - * This module imports `Descriptor.js` files only so that headless validation can be done withou - * the bundle size cost of the editors. + * This module imports `Descriptor.js` files only so that headless validation does not load the + * interaction editors. It does load TipTap's extensions: rich text comparison keys parse with + * their schema. * * Registering a new interaction means adding its descriptor here and its editor in index.js */ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue index 30017aa6e8..d663179866 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Editor.vue @@ -309,8 +309,9 @@ import ClickableRegion from '../../components/ClickableRegion/index.vue'; import EditableChipList from '../../components/EditableChipList/index.vue'; import ShuffledResponsePool from '../../components/ShuffledResponsePool/index.vue'; - import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; + import { hasRichTextContent } from '../../utils/richText'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor'; + import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService'; /** Which card or chip list currently holds the open TipTap editor. */ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/parse.spec.js index 9f0e6f1469..7bf8dd43a0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/parse.spec.js @@ -23,6 +23,9 @@ const declaration = (...values) => ${values.map(v => `${v}`).join('')} `; +const SOLVE_X2 = '

Solve for x

'; +const SOLVE_Y3 = '

Solve for x

'; + const ROWS = matchSet(choice('row_dog', 'Dog') + choice('row_eagle', 'Eagle')); const RESPONSES = matchSet( choice('choice_mammal', 'Mammal') + choice('choice_bird', 'Bird') + choice('choice_fur', 'Fur'), @@ -484,6 +487,27 @@ describe('parse → buildXML → parse round-trip', () => { expect(roundTrip(state)).toEqual(state); }); + it.each([ + ['formulas', SOLVE_X2, SOLVE_Y3], + [ + 'SVGs', + '

', + '

', + ], + ['scripts', '

x2

', '

x2

'], + ])('preserves answers that differ only in their %s', (_, first, second) => { + const state = { + responseIdentifier: 'RESPONSE', + prompt: '', + rows: [ + { id: 'row_dog', content: 'Dog', matches: [{ id: 'choice_a', content: first }] }, + { id: 'row_eagle', content: 'Eagle', matches: [{ id: 'choice_b', content: second }] }, + ], + distractors: [], + }; + expect(matchContents(roundTrip(state).rows)).toEqual([[first], [second]]); + }); + it('preserves the default state', () => { const original = parse('', []); expect(roundTrip(original)).toEqual(original); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/validation.spec.js index 79e6d1546d..e6f74c2a84 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/validation.spec.js @@ -1,6 +1,6 @@ import { validateMatchInteraction as validate } from '../validation'; import { ValidationError } from '../../../constants'; -import { richTextComparisonKey } from '../../../utils/richText'; +import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; const row = (index, content, matches) => ({ id: `row_${index}`, content, matches }); const answer = (id, content) => ({ id, content }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js index ae39c65d61..c498bc8e4a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js @@ -2,9 +2,10 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; -import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; +import { hasRichTextContent } from '../../utils/richText'; import { RESPONSE_IDENTIFIER } from '../../constants'; import { _extractCorrectPairIds, newChoice } from '../associate/parse'; +import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; const serializer = new XMLSerializer(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/validation.js index a2e93bf78a..71195193e1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/validation.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/validation.js @@ -1,5 +1,6 @@ import { ValidationError } from '../../constants'; -import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; +import { hasRichTextContent } from '../../utils/richText'; +import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; /** * Validate MatchState → ValidationError[]. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validation.spec.js index f8856eaf52..ee9aa92444 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validation.spec.js @@ -157,5 +157,19 @@ describe('validateOrderingInteraction()', () => { ValidationError.DUPLICATE_CHOICE_CONTENT, ); }); + + it.each([ + ['a formula', '', ''], + ['an image', '', ''], + ['a script', '2', '2'], + ])('does not flag items with the same text but %s that differs', (_, first, second) => { + const state = makeState({ + items: [ + makeItem({ id: 'a', content: `

Solve ${first} for x

` }), + makeItem({ id: 'b', content: `

Solve ${second} for x

` }), + ], + }); + expect(validateOrderingInteraction(state)).toEqual([]); + }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validation.js index bc8e68d0c1..4f60436825 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validation.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validation.js @@ -1,5 +1,6 @@ import { ValidationError } from '../../constants'; -import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; +import { hasRichTextContent } from '../../utils/richText'; +import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; /** * Validate OrderingState → ValidationError[]. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js index d398d94b0c..982a2d359f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js @@ -1,4 +1,4 @@ -import { hasRichTextContent, richTextComparisonKey } from '../richText'; +import { hasRichTextContent } from '../richText'; describe('hasRichTextContent', () => { it.each([ @@ -32,29 +32,3 @@ describe('hasRichTextContent', () => { expect(hasRichTextContent(content)).toBe(expected); }); }); - -describe('richTextComparisonKey', () => { - it('reads through markup, so the same words marked up differently are one key', () => { - expect(richTextComparisonKey('

Paris

')).toBe( - richTextComparisonKey('

Paris

'), - ); - }); - - it('keeps two different formulas apart when there is no text to compare', () => { - expect(richTextComparisonKey('

')).not.toBe( - richTextComparisonKey('

'), - ); - }); - - it('keeps two different images apart when there is no text to compare', () => { - expect(richTextComparisonKey('

')).not.toBe( - richTextComparisonKey('

'), - ); - }); - - it('still matches the same image offered twice', () => { - expect(richTextComparisonKey('

')).toBe( - richTextComparisonKey('

\n \n

'), - ); - }); -}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js index 622a3f823b..9ead9a85fe 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js @@ -49,21 +49,3 @@ export function hasRichTextContent(content) { Boolean(parseXML(content ?? '', 'text/html').querySelector(EMBEDDED_MEDIA)) ); } - -/** - * A key two fragments can be compared on to tell whether they say the same thing. - * - * Text is what an author reads, so text is what duplicates are judged on — two choices - * that read the same are the same choice however differently they are marked up. A - * fragment with no text has only its markup to go on, which keeps two different images - * apart while still catching the same image offered twice — read back out of stored XML - * it arrives pretty-printed, so the markup is compared with its layout normalised away. - * - * Only meaningful for a fragment that `hasRichTextContent`; an empty one keys to ''. - * - * @param {string} content - HTML fragment from a TipTap editor - * @returns {string} - */ -export function richTextComparisonKey(content) { - return toText(content) || (content ?? '').replace(/\s+/g, ' ').replace(/>\s+<').trim(); -} diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js index b57916c10f..e416af5955 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js @@ -30,6 +30,39 @@ const INLINE_ONLY_STARTER_KIT = { trailingNode: false, }; +export function editorExtensions({ inlineOnly = false } = {}) { + return [ + StarterKitExtension.configure({ + codeBlock: false, // Disable default code block to use the extended version + // A link has nothing to navigate to on a device with no internet access, so + // the editor offers none and the legacy conversion unwraps the ones it finds + // (utils/assessment/qti/convert.py). Dropping the mark rather than only the + // toolbar button is what keeps a pasted anchor from arriving as one. + link: false, + // Replaced by the versions in extensions/TextDecoration.js, which write the + // decoration as a style on a — the QTI 3.0 HTML profile has no or . + strike: false, + underline: false, + ...(inlineOnly && INLINE_ONLY_STARTER_KIT), + }), + ...(inlineOnly + ? [InlineDocument] + : [ + CodeBlockSyntaxHighlight, + Small, + Image, + TextAlign.configure({ + types: ['heading', 'paragraph', 'image', 'small'], + }), + ]), + StyledStrike, + StyledUnderline, + Superscript, + Subscript, + Math, + ]; +} + // Whether replacing the selection would delete a line break, welding two lines. function spansLines({ doc, selection }) { if (selection.empty) return false; @@ -64,37 +97,7 @@ export function useEditor() { editor.value = new Editor({ autofocus, editable: mode === 'edit', - extensions: [ - StarterKitExtension.configure({ - codeBlock: false, // Disable default code block to use the extended version - // A link has nothing to navigate to on a device with no internet access, so - // the editor offers none and the legacy conversion unwraps the ones it finds - // (utils/assessment/qti/convert.py). Dropping the mark rather than only the - // toolbar button is what keeps a pasted anchor from arriving as one. - link: false, - // Replaced by the versions in extensions/TextDecoration.js, which write the - // decoration as a style on a — the QTI 3.0 HTML profile has no or . - strike: false, - underline: false, - ...(inlineOnly && INLINE_ONLY_STARTER_KIT), - }), - ...(inlineOnly - ? [InlineDocument] - : [ - CodeBlockSyntaxHighlight, - Small, - Image, - TextAlign.configure({ - types: ['heading', 'paragraph', 'image', 'small'], - }), - ]), - StyledStrike, - StyledUnderline, - Superscript, - Subscript, - Math, - ...extensions, - ], + extensions: [...editorExtensions({ inlineOnly }), ...extensions], content: content || '

', editorProps: { attributes: { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey.js new file mode 100644 index 0000000000..c3efd606f1 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey.js @@ -0,0 +1,80 @@ +import omit from 'lodash/omit'; +import { getSchema } from '@tiptap/core'; +// 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'; + +// The full schema, so inline and block content of the same text read the same. +const schema = getSchema(editorExtensions()); +const parser = PMDOMParser.fromSchema(schema); + +// Media the schema has no node for, so parsing would drop it. +const UNPARSED_MEDIA = 'svg, math'; +// Marks that change what text says. +const SCRIPTS = ['superscript', 'subscript']; + +/** + * A key two fragments can be compared on to tell whether they say the same thing. + * + * Read as TipTap saves it, so its re-render of a fragment keys the same: its text, + * however marked up except its sub/superscripts, and each formula, image, SVG and + * MathML where it stands. + * + * @param {string} html - HTML fragment from a TipTap editor + * @returns {string} + */ +export function richTextComparisonKey(html) { + const document = new DOMParser().parseFromString(html ?? '', 'text/html'); + // A formula stands in for each, so parsing keeps its place. HTML parsing turns NUL + // into U+FFFD, so no stored formula has this LaTeX. + const unparsed = new Map(); + for (const element of document.body.querySelectorAll(UNPARSED_MEDIA)) { + const latex = `\0${unparsed.size}`; + unparsed.set(latex, ['html', element.outerHTML.replace(/\s+/g, ' ').replace(/>\s+<')]); + const placeholder = document.createElement('span'); + placeholder.setAttribute('data-latex', latex); + element.replaceWith(placeholder); + } + const parts = []; + let text = ''; + let script = null; + // Whitespace at a line's edges is not seen, nor kept by parsing TipTap's output, e.g. + // after an image split out of a paragraph. + let lineStart = false; + const endText = () => { + if (text) parts.push(script ? [script, text] : text); + text = ''; + }; + const read = node => { + if (node.isText) { + const nodeScript = + node.marks.find(mark => SCRIPTS.includes(mark.type.name))?.type.name ?? null; + if (nodeScript !== script) { + endText(); + script = nodeScript; + } + const value = lineStart ? node.text.trimStart() : node.text; + lineStart = lineStart && !value; + text += value; + } else if (node.type.name === 'math') { + endText(); + parts.push(unparsed.get(node.attrs.latex) ?? ['latex', node.attrs.latex]); + lineStart = false; + } else if (node.type.name === 'image') { + endText(); + // 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') { + lineStart = true; + node.forEach(read); + text = text.trimEnd(); + } else { + node.forEach(read); + } + }; + read(parser.parse(document.body)); + endText(); + return JSON.stringify(parts); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/comparisonKey.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/comparisonKey.spec.js new file mode 100644 index 0000000000..ac32857c7c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/comparisonKey.spec.js @@ -0,0 +1,231 @@ +import { useEditor } from '../TipTapEditor/composables/useEditor'; +import { richTextComparisonKey } from '../TipTapEditor/utils/comparisonKey'; + +describe('richTextComparisonKey', () => { + it('reads through markup, so the same words marked up differently are one key', () => { + expect(richTextComparisonKey('

Paris

')).toBe( + richTextComparisonKey('

Paris

'), + ); + }); + + it.each([ + ['bold', '

x2

'], + ['italic', '

x2

'], + ['underline', '

x2

'], + ['strikethrough', '

x2

'], + ])('reads %s as the same text', (_, marked) => { + expect(richTextComparisonKey(marked)).toBe(richTextComparisonKey('

x2

')); + }); + + it('keeps superscript, subscript and plain text apart', () => { + const keys = ['

x2

', '

x2

', '

x2

'].map( + richTextComparisonKey, + ); + expect(new Set(keys).size).toBe(3); + }); + + it.each([ + ['formula', '', ''], + ['image', '', ''], + ['SVG', '', ''], + ['MathML', 'x', 'y'], + ])('keeps two different %s apart', (_, first, second) => { + expect(richTextComparisonKey(`

${first}

`)).not.toBe( + richTextComparisonKey(`

${second}

`), + ); + expect(richTextComparisonKey(`

a ${first}

`)).not.toBe( + richTextComparisonKey(`

a ${second}

`), + ); + }); + + it('reads the same SVG laid out differently as one', () => { + expect(richTextComparisonKey('

')).toBe( + richTextComparisonKey('

\n \n \n \n

'), + ); + }); + + it.each([ + ['formula', ''], + ['MathML', '5'], + ['SVG', ''], + ])('keeps the same text and %s apart when it moves', (_, media) => { + expect(richTextComparisonKey(`

${media} cm

`)).not.toBe( + richTextComparisonKey(`

cm ${media}

`), + ); + expect(richTextComparisonKey(`

a${media}b

`)).not.toBe( + richTextComparisonKey(`

ab${media}

`), + ); + }); + + it('keeps a formula apart from its LaTeX typed as text', () => { + expect(richTextComparisonKey('

a

')).not.toBe( + richTextComparisonKey('

a x^2

'), + ); + }); + + it('keeps a formula apart from its key typed as text', () => { + const key = richTextComparisonKey('

a

'); + expect(richTextComparisonKey(`

${key}

`)).not.toBe(key); + }); + + describe.each([ + ['beside text', 'see '], + ['alone', ''], + ])('an image %s', (_, text) => { + const key = image => richTextComparisonKey(`

${text}${image}

`); + + it.each([ + ['alt text', 'a cat', 'a dog'], + ['alignment', '', ''], + ['style', '', ''], + ])('is kept apart when its %s differs', (_, first, second) => { + expect(key(first)).not.toBe(key(second)); + }); + + it('reads attribute order and the default alignment as the same image', () => { + expect(key('a cat')).toBe( + key('a cat'), + ); + }); + + it('reads the same image at another size as the same image', () => { + expect(key('')).toBe(key('')); + }); + + it.each([ + ['title', ''], + ['class', ''], + ['size style', ''], + ['saved upload', ''], + ])('with a %s it does not show reads as the same image', (_, other) => { + expect(key(other)).toBe(key('')); + }); + }); + + it.each([ + ['formula', '', ''], + ['image', '', ''], + ['MathML', 'a', 'b'], + ])('keeps the same code apart when its %s differs', (_, first, second) => { + expect(richTextComparisonKey(`
x ${first}
`)).not.toBe( + richTextComparisonKey(`
x ${second}
`), + ); + }); + + it('keeps the indentation of a code block', () => { + expect(richTextComparisonKey('
if x:\n    return 1
')).not.toBe( + richTextComparisonKey('
if x:\n  return 1
'), + ); + }); + + it.each([ + ['paragraphs', '

a

b

'], + ['a line break', '

a
b

'], + ['list items', '
  • a
  • b
'], + ])('reads text split over %s as the same text on one line', (_, split) => { + expect(richTextComparisonKey(split)).toBe(richTextComparisonKey('

ab

')); + }); + + it('reads text in a code block as the same text in a paragraph', () => { + expect(richTextComparisonKey('
ab
')).toBe( + richTextComparisonKey('

ab

'), + ); + }); + + it.each([ + ['a trailing', '

Paris 

'], + ['a leading', '

 Paris

'], + ['an empty paragraph of', '

Paris

 

'], + ['a line-break-ended', '

Paris 
 

'], + ])('drops %s non-breaking space', (_, padded) => { + expect(richTextComparisonKey(padded)).toBe(richTextComparisonKey('

Paris

')); + }); + + it.each([ + ['

Paris 

', '

Paris

'], + [ + '

 

a

', + '

a

', + ], + ])('drops a non-breaking space at the end of a line in %j', (padded, plain) => { + expect(richTextComparisonKey(padded)).toBe(richTextComparisonKey(plain)); + }); + + it('keeps a non-breaking space inside text apart from a space', () => { + expect(richTextComparisonKey('

a b

')).not.toBe(richTextComparisonKey('

a b

')); + }); + + it('reads an entity in plain text as the character it stands for', () => { + expect(richTextComparisonKey('Salt & pepper')).toBe( + richTextComparisonKey('

Salt & pepper

'), + ); + }); + + describe('after TipTap rewraps it', () => { + let editors = []; + + const rewrap = (html, edit = () => {}) => { + const { initializeEditor, editor } = useEditor(); + initializeEditor(html); + editors.push(editor.value); + edit(editor.value); + return editor.value.getHTML(); + }; + + afterEach(() => { + editors.forEach(editor => editor.destroy()); + editors = []; + }); + + it.each([ + '

see here

', + '

\n \n caption\n

', + '

see a cat

', + '

Solve\n \n for x

', + '

a

\n

', + '

a

\n

\n \n

', + '

see

', + '', + '

a cat

', + '

', + '
if x:\n    return 1
', + '

a
b

', + '

a
\n b

', + ])('%j keys the same', stored => { + expect(richTextComparisonKey(rewrap(stored))).toBe(richTextComparisonKey(stored)); + }); + + it('keys an image sized when it loads the same as unsized', () => { + const stored = '

see

'; + // As ImageNodeView does once the image loads. + const resizeImages = editor => + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'image') { + editor.view.dispatch( + editor.state.tr.setNodeMarkup(pos, null, { ...node.attrs, width: 300, height: 200 }), + ); + } + }); + expect(richTextComparisonKey(rewrap(stored, resizeImages))).toBe( + richTextComparisonKey(stored), + ); + }); + }); + + it('keeps the spaces around a formula', () => { + expect(richTextComparisonKey('

a b

')).not.toBe( + richTextComparisonKey('

ab

'), + ); + }); + + it.each([ + ['in text', '

a b

', '

a b

'], + [ + 'beside a formula', + '

a

', + '

a

', + ], + ])('collapses runs of whitespace %s', (_, run, single) => { + expect(richTextComparisonKey(run)).toBe(richTextComparisonKey(single)); + }); +});