diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js new file mode 100644 index 0000000000..dff171dba1 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js @@ -0,0 +1,229 @@ +// The resolver does not read package `exports` subpaths. +/* eslint-disable import/no-unresolved */ +import { isHistoryTransaction } from '@tiptap/pm/history'; +import { Fragment, Slice } from '@tiptap/pm/model'; +import { NodeSelection, Plugin } from '@tiptap/pm/state'; +/* eslint-enable import/no-unresolved */ +import { + Node, + combineTransactionSteps, + findChildren, + findChildrenInRange, + getChangedRanges, +} from '@tiptap/core'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { generateRandomSlug } from '../../utils/generateRandomSlug'; +import InlineChoiceChip from './components/InlineChoiceChip'; +import { CORRECT_ATTR, DROPDOWN, OPTION, readDropdown } from './parse'; + +const NODE_NAME = 'inlineChoice'; + +const isChip = node => node.type.name === NODE_NAME; + +export const findChip = (doc, responseIdentifier) => + findChildren( + doc, + node => isChip(node) && node.attrs.responseIdentifier === responseIdentifier, + )[0]; + +function* chipsIn(fragment) { + for (let i = 0; i < fragment.childCount; i++) { + const child = fragment.child(i); + if (isChip(child)) yield child; + else yield* chipsIn(child.content); + } +} + +function idsOf(attrs) { + return [attrs.responseIdentifier, ...attrs.options.map(option => option.id)]; +} + +const claimIds = (taken, attrs) => idsOf(attrs).forEach(id => taken.add(id)); + +// A missing id needs a fresh one too: the save leaves it missing. +function needsFreshIds(attrs, taken) { + const ids = idsOf(attrs); + return new Set(ids).size !== ids.length || ids.some(id => !id || taken.has(id)); +} + +// Renames only the missing and taken ids, so a cut-paste keeps the chip's identity. +function withFreshIds({ responseIdentifier, options, correctId }, taken) { + const seen = new Set(taken); + const fresh = (id, prefix) => { + if (!id || seen.has(id)) return generateRandomSlug(prefix); + seen.add(id); + return id; + }; + const renamed = { responseIdentifier: fresh(responseIdentifier, 'response') }; + renamed.options = options.map(option => ({ ...option, id: fresh(option.id, 'choice') })); + const correctIndex = options.findIndex(option => option.id === correctId); + renamed.correctId = correctIndex === -1 ? null : renamed.options[correctIndex].id; + return renamed; +} + +const isSpace = (doc, pos) => /\s/.test(doc.textBetween(pos, pos + 1)); + +function trimmedRange({ doc, selection }) { + let { from, to } = selection; + while (from < to && isSpace(doc, from)) from++; + while (to > from && isSpace(doc, to - 1)) to--; + return { from, to }; +} + +function reidentify(fragment, taken) { + const children = []; + fragment.forEach(child => { + if (isChip(child)) { + const node = needsFreshIds(child.attrs, taken) + ? child.type.create(withFreshIds(child.attrs, taken)) + : child; + claimIds(taken, node.attrs); + children.push(node); + } else { + children.push(child.copy(reidentify(child.content, taken))); + } + }); + return Fragment.fromArray(children); +} + +export const InlineChoiceNode = Node.create({ + name: NODE_NAME, + group: 'inline', + inline: true, + atom: true, + + addAttributes() { + return { + responseIdentifier: { default: '', rendered: false }, + options: { default: [], rendered: false }, + correctId: { default: null, rendered: false }, + }; + }, + + parseHTML() { + return [{ tag: DROPDOWN, getAttrs: readDropdown }]; + }, + + renderHTML({ node }) { + const { responseIdentifier, options, correctId } = node.attrs; + const attrs = { 'response-identifier': responseIdentifier }; + if (correctId !== null) attrs[CORRECT_ATTR] = correctId; + return [ + DROPDOWN, + attrs, + ...options.map(option => [OPTION, { identifier: option.id }, option.text]), + ]; + }, + + addCommands() { + return { + // The selected text becomes the chip's correct option. With no text, or with an inline + // atom (a chip, a formula) that plain text cannot hold, the chip goes after the selection, + // so nothing is deleted. + insertInlineChoice: + responseIdentifier => + ({ tr, commands }) => { + const { from, to } = trimmedRange(tr); + const { selection } = tr; + let hasAtom = false; + tr.doc.nodesBetween(selection.from, selection.to, node => { + if (node.isInline && !node.isText) hasAtom = true; + return !hasAtom; + }); + const text = hasAtom ? '' : tr.doc.textBetween(from, to); + const option = { id: generateRandomSlug('choice'), text }; + const attrs = { + responseIdentifier, + options: [option], + correctId: text ? option.id : null, + }; + // Places the cursor after the chip, even when it has to be wrapped in a new paragraph. + return commands.insertContentAt(text ? { from, to } : selection.to, { + type: NODE_NAME, + attrs, + }); + }, + // A chip needs at least one option, each with an id, to be schema-valid. + updateInlineChoice: + (responseIdentifier, { options, correctId }) => + ({ tr, state, dispatch }) => { + const found = findChip(state.doc, responseIdentifier); + if (!found || (options && !options.length)) return false; + const attrs = { ...found.node.attrs }; + if (options) { + attrs.options = options.map(option => + option.id ? option : { ...option, id: generateRandomSlug('choice') }, + ); + } + if (correctId !== undefined) attrs.correctId = correctId; + if (!attrs.options.some(option => option.id === attrs.correctId)) attrs.correctId = null; + if (dispatch) { + const wasSelected = tr.selection.node && tr.selection.from === found.pos; + // Replaces the chip, so history groups quick successive edits as it does typing. + tr.setNodeMarkup(found.pos, undefined, attrs); + if (wasSelected) tr.setSelection(NodeSelection.create(tr.doc, found.pos)); + } + return true; + }, + }; + }, + + addNodeView() { + return VueNodeViewRenderer(InlineChoiceChip); + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + // A copy must not share ids with the original; a cut-paste has already removed them. + transformPasted(slice, view) { + // Whether a drag moves or copies is only known at drop; see `appendTransaction`. + if (view.dragging || chipsIn(slice.content).next().done) return slice; + const taken = new Set(); + for (const { node } of findChildren(view.state.doc, isChip)) + claimIds(taken, node.attrs); + return new Slice(reidentify(slice.content, taken), slice.openStart, slice.openEnd); + }, + }, + // Catches inserts `transformPasted` does not see, such as a drop. An inserted chip whose + // ids are still in use elsewhere was copied, not moved. + appendTransaction(transactions, oldState, newState) { + // Undo and redo only restore states this plugin already accepted. + if (transactions.every(isHistoryTransaction)) return null; + if (!transactions.some(tr => tr.docChanged)) return null; + const changed = getChangedRanges(combineTransactionSteps(oldState.doc, transactions)); + const inserted = new Map(); + for (const { newRange } of changed) { + for (const { node, pos } of findChildrenInRange(newState.doc, newRange, isChip)) { + inserted.set(pos, node); + } + } + if (!inserted.size) return null; + const taken = new Set(); + for (const { node, pos } of findChildren(newState.doc, isChip)) { + if (!inserted.has(pos)) claimIds(taken, node.attrs); + } + // An edited or moved chip keeps its ids unless another chip has its response identifier, + // which only a copy shares; repeated choice ids are left to the save. + const existing = new Set( + findChildren(oldState.doc, isChip).map(({ node }) => node.attrs.responseIdentifier), + ); + let tr = null; + for (const [pos, node] of [...inserted].sort(([a], [b]) => a - b)) { + let { attrs } = node; + const stale = existing.has(attrs.responseIdentifier) + ? taken.has(attrs.responseIdentifier) + : needsFreshIds(attrs, taken); + if (stale) { + attrs = withFreshIds(attrs, taken); + tr = (tr || newState.tr).setNodeMarkup(pos, undefined, attrs); + } + claimIds(taken, attrs); + } + return tr; + }, + }), + ]; + }, +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js new file mode 100644 index 0000000000..5ddfe7bdda --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js @@ -0,0 +1,260 @@ +import { nextTick, ref } from 'vue'; +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { qtiEditorStrings } from '../../../qtiEditorStrings'; +import { CHIP, chip, findChip, renderPassage } from './renderPassage'; +import { stubProseMirrorLayout } from 'shared/utils/testing'; + +// jsdom defines `ontouchstart`, which would put the editor in its touch layout. +jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false })); + +const getChip = () => screen.getByRole('button', CHIP); +const getChips = () => screen.getAllByRole('button', CHIP); + +const chipPos = (editor, responseIdentifier) => findChip(editor, responseIdentifier).pos; + +describe('InlineChoiceChip', () => { + stubProseMirrorLayout(); + + it('shows the correct option text and counts only options with text', async () => { + const value = `

${chip( + 'r1', + [ + ['a', 'Sun'], + ['b', ' '], + ['c', 'Moon'], + ['d', ''], + ], + 'c', + )}

`; + await renderPassage({ value }); + const el = getChip(); + expect(el).toHaveTextContent('2 Moon'); + expect(el).toHaveAccessibleName( + qtiEditorStrings.answerDropdownWithCorrect$({ count: 2, label: 'Moon' }), + ); + expect(el).toHaveAccessibleName(/^Moon, /); + }); + + it.each([ + ['options have text but none is correct', [['a', 'Sun']], undefined, 1], + ['no option has text', [['a', '']], undefined, 0], + [ + 'the correct option has blank text', + [ + ['a', ' '], + ['b', 'x'], + ], + 'a', + 1, + ], + ])('prompts for answers when %s', async (_, options, correct, count) => { + await renderPassage({ value: `

${chip('r1', options, correct)}

` }); + const el = getChip(); + expect(el).toHaveTextContent(`${count} ${qtiEditorStrings.addAnswers$()}`); + expect(el).toHaveAccessibleName( + qtiEditorStrings.answerDropdownNoCorrect$({ count, label: qtiEditorStrings.addAnswers$() }), + ); + }); + + it('is a button that opens a dialog, closed until opened', async () => { + await renderPassage({ value: `

${chip('r1', [['a', 'x']], 'a')}

` }); + const el = getChip(); + expect(el).toHaveAttribute('type', 'button'); + expect(el).toHaveAttribute('aria-haspopup', 'dialog'); + expect(el).toHaveAttribute('aria-expanded', 'false'); + }); + + it('is no Tab stop or popup while the passage is read-only, and a button once editable', async () => { + const { openResponseIdentifier, setMode } = await renderPassage({ + value: `

a${chip('r1', [['a', 'x']], 'a')}

`, + mode: 'view', + }); + expect(screen.queryByRole('button', CHIP)).not.toBeInTheDocument(); + const el = screen.getByRole('img', CHIP); + expect(el).not.toHaveAttribute('aria-expanded'); + await fireEvent.click(el); + expect(openResponseIdentifier.value).toBeNull(); + + setMode('edit'); + await waitFor(() => expect(getChip()).toHaveAttribute('aria-expanded', 'false')); + }); + + it('opens the clicked chip and closes the previously open one', async () => { + const value = `

${chip('r1', [['a', 'x']], 'a')} ${chip('r2', [['b', 'y']], 'b')}

`; + const { openResponseIdentifier } = await renderPassage({ value }); + const [first, second] = getChips(); + await userEvent.click(first); + expect(openResponseIdentifier.value).toBe('r1'); + await waitFor(() => expect(first).toHaveAttribute('aria-expanded', 'true')); + await userEvent.click(second); + expect(openResponseIdentifier.value).toBe('r2'); + await waitFor(() => expect(second).toHaveAttribute('aria-expanded', 'true')); + expect(first).toHaveAttribute('aria-expanded', 'false'); + }); + + it.each(['{Enter}', ' '])('opens a focused chip with %s', async key => { + const { editor, openResponseIdentifier } = await renderPassage({ + value: `

a${chip('r1', [['a', 'x']], 'a')}b

`, + }); + const before = editor.getHTML(); + getChip().focus(); + await userEvent.keyboard(key); + expect(openResponseIdentifier.value).toBe('r1'); + expect(editor.getHTML()).toBe(before); + }); + + it('is one Tab stop per chip, in reading order, after the editor', async () => { + const value = `

a${chip('r1', [['a', 'x']], 'a')}

b${chip('r2', [['b', 'y']], 'b')}

`; + const { editor } = await renderPassage({ value }); + const [first, second] = getChips(); + editor.view.dom.focus(); + await userEvent.tab(); + expect(first).toHaveFocus(); + await userEvent.tab(); + expect(second).toHaveFocus(); + await userEvent.tab(); + expect(editor.view.dom.contains(document.activeElement)).toBe(false); + }); + + // ProseMirror reads `keyCode`, which `userEvent.keyboard` does not set. + const press = (editor, key, keyCode, init = {}) => + fireEvent.keyDown(editor.view.dom, { key, keyCode, ...init }); + + it.each([ + ['ArrowRight', 39, 2], + ['ArrowLeft', 37, 3], + ])('does not open a chip reached with %s', async (key, keyCode, cursor) => { + const { editor, openResponseIdentifier } = await renderPassage({ + value: `

a${chip('r1', [['c1', 'x']], 'c1')}b

`, + }); + editor.commands.setTextSelection(cursor); + press(editor, key, keyCode); + expect(editor.state.selection.node).toBeDefined(); + await nextTick(); + expect(openResponseIdentifier.value).toBeNull(); + expect(getChip()).toHaveAttribute('aria-expanded', 'false'); + }); + + it.each([ + ['Enter', 13], + [' ', 32], + ])('does not open a chip the caret is on when %p is pressed', async (key, keyCode) => { + const { editor, openResponseIdentifier } = await renderPassage({ + value: `

a${chip('r1', [['c1', 'x']], 'c1')}b

`, + }); + editor.commands.setNodeSelection(chipPos(editor, 'r1')); + press(editor, key, keyCode); + await nextTick(); + expect(openResponseIdentifier.value).toBeNull(); + }); + + it('deletes a keyboard-selected chip with Backspace and restores it with undo', async () => { + const { editor } = await renderPassage({ + value: `

a${chip('r1', [['c1', 'x']], 'c1')}b

`, + }); + editor.commands.setTextSelection(2); + press(editor, 'ArrowRight', 39); + press(editor, 'Backspace', 8); + await waitFor(() => expect(screen.queryByRole('button', CHIP)).not.toBeInTheDocument()); + press(editor, 'z', 90, { ctrlKey: true }); + await waitFor(() => expect(getChip()).toHaveTextContent('1 x')); + }); + + it('focuses a chip by its response identifier, or the editor once it is gone', async () => { + const value = `

a${chip('r1', [['a', 'x']], 'a')} ${chip('r2', [['b', 'y']], 'b')}

`; + const { editor, focusChip } = await renderPassage({ value }); + focusChip('r2'); + expect(getChips()[1]).toHaveFocus(); + + editor.commands.setNodeSelection(chipPos(editor, 'r2')); + editor.commands.deleteSelection(); + focusChip('r2'); + await waitFor(() => expect(editor.view.dom).toHaveFocus()); + }); + + it('ignores focusing a chip once the editor is gone', async () => { + const { focusChip } = await renderPassage({ value: `

${chip('r1', [['a', 'x']], 'a')}

` }); + cleanup(); + expect(() => focusChip('r1')).not.toThrow(); + expect(() => focusChip('gone')).not.toThrow(); + }); + + it('outlines a node-selected chip without opening it', async () => { + const { editor, openResponseIdentifier } = await renderPassage({ + value: `

a${chip('r1', [['c1', 'x']], 'c1')}b

`, + }); + editor.commands.setNodeSelection(chipPos(editor, 'r1')); + await waitFor(() => expect(getChip()).toHaveClass('is-selected')); + expect(openResponseIdentifier.value).toBeNull(); + + editor.commands.setTextSelection(1); + await waitFor(() => expect(getChip()).not.toHaveClass('is-selected')); + }); + + // The open chip's panel (#6182) sits outside the editor; using it must not close the editor. + // Relies on `useClickOutside`'s `hasOpenMenu` matching the chip's `aria-expanded` button. + it('keeps the editor open on a click outside it while a chip is open', async () => { + const { onMinimize } = await renderPassage({ + value: `

${chip('r1', [['a', 'x']], 'a')}

`, + }); + await userEvent.click(getChip()); + await waitFor(() => expect(getChip()).toHaveAttribute('aria-expanded', 'true')); + await userEvent.click(document.body); + expect(onMinimize).not.toHaveBeenCalled(); + }); + + it('shows errors while its response identifier is in the error list', async () => { + const errorResponseIdentifiers = ref([]); + await renderPassage({ + value: `

${chip('r1', [['a', 'x']], 'a')}

`, + errorResponseIdentifiers, + }); + const el = getChip(); + expect(el).not.toHaveClass('has-errors'); + expect(el).toHaveAccessibleName( + qtiEditorStrings.answerDropdownWithCorrect$({ count: 1, label: 'x' }), + ); + + errorResponseIdentifiers.value = ['r1']; + await nextTick(); + expect(el).toHaveClass('has-errors'); + expect(el).toHaveAccessibleName( + qtiEditorStrings.answerDropdownWithCorrectNeedsAttention$({ count: 1, label: 'x' }), + ); + + errorResponseIdentifiers.value = []; + await nextTick(); + expect(el).not.toHaveClass('has-errors'); + expect(el).toHaveAccessibleName( + qtiEditorStrings.answerDropdownWithCorrect$({ count: 1, label: 'x' }), + ); + }); + + it('says a dropdown with no correct answer needs attention', async () => { + await renderPassage({ + value: `

${chip('r1', [['a', '']])}

`, + errorResponseIdentifiers: ['r1'], + }); + expect(getChip()).toHaveAccessibleName( + qtiEditorStrings.answerDropdownNoCorrectNeedsAttention$({ + count: 0, + label: qtiEditorStrings.addAnswers$(), + }), + ); + }); + + it('updates the label in place when the dropdown changes', async () => { + const { editor } = await renderPassage({ value: `

${chip('r1', [['a', 'x']], 'a')}

` }); + const el = getChip(); + editor.commands.updateInlineChoice('r1', { + options: [ + { id: 'a', text: 'x' }, + { id: 'b', text: 'y' }, + ], + correctId: 'b', + }); + await waitFor(() => expect(el).toHaveTextContent('2 y')); + expect(getChip()).toBe(el); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js new file mode 100644 index 0000000000..202a5b6911 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js @@ -0,0 +1,463 @@ +import { screen, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +// The resolver does not read package `exports` subpaths. +// eslint-disable-next-line import/no-unresolved +import { closeHistory } from '@tiptap/pm/history'; +import { getDropdowns, parseInlineChoiceInteraction } from '../parse'; +import { chip, findChip, renderPassage } from './renderPassage'; +import { stubProseMirrorLayout } from 'shared/utils/testing'; +import { getTipTapEditorStrings } from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditorStrings'; + +const { copy$, paste$ } = getTipTapEditorStrings(); + +// jsdom defines `ontouchstart`, which would put the editor in its touch layout. +jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false })); + +// jsdom has no ClipboardEvent, which `pasteHTML` creates by default. +const paste = (editor, html) => editor.view.pasteHTML(html, new Event('paste')); + +describe('InlineChoiceNode', () => { + stubProseMirrorLayout(); + + it('returns passage HTML with chips unchanged, in a list item and a heading too', async () => { + const bodyXml = + '

Sky is ' + + chip( + 'response_a', + [ + ['choice_a', 'blue'], + ['choice_b', 'red'], + ], + 'choice_a', + ) + + '

' + + chip('response_c', [['choice_d', 'x']], 'choice_d') + + '

'; + const { passage } = parseInlineChoiceInteraction(bodyXml, []); + const { editor } = await renderPassage({ value: passage }); + expect(editor.getHTML()).toBe(passage); + }); + + it('holds the dropdowns getDropdowns reads, including a blank and an unmarked chip', async () => { + const value = `

${chip( + 'r1', + [ + ['c1', 'a'], + ['c2', ''], + ], + 'c1', + )} ${chip('r2', [['c3', '']])}

`; + const { editor } = await renderPassage({ value }); + const attrs = ['r1', 'r2'].map(id => findChip(editor, id).node.attrs); + expect(getDropdowns(editor.getHTML())).toEqual(attrs); + expect(attrs[1]).toEqual({ + responseIdentifier: 'r2', + options: [{ id: 'c3', text: '' }], + correctId: null, + }); + }); + + it('restores a deleted chip with its options and correct answer on undo', async () => { + const value = `

a${chip( + 'r1', + [ + ['c1', 'x'], + ['c2', 'y'], + ], + 'c2', + )}b

`; + const { editor } = await renderPassage({ value }); + const { node, pos } = findChip(editor, 'r1'); + editor.commands.setNodeSelection(pos); + editor.commands.deleteSelection(); + expect(findChip(editor, 'r1')).toBeNull(); + + editor.commands.undo(); + expect(findChip(editor, 'r1').node.attrs).toEqual(node.attrs); + }); + + describe('updateInlineChoice', () => { + const value = `

${chip( + 'r1', + [ + ['c1', 'x'], + ['c2', 'y'], + ], + 'c1', + )}

`; + + it('is one undo step and one redo step', async () => { + const { editor } = await renderPassage({ value }); + const before = editor.getHTML(); + editor.commands.updateInlineChoice('r1', { + options: [ + { id: 'c1', text: 'x2' }, + { id: 'c2', text: 'y' }, + ], + correctId: 'c2', + }); + const after = editor.getHTML(); + expect(after).not.toBe(before); + + editor.commands.undo(); + expect(editor.getHTML()).toBe(before); + editor.commands.redo(); + expect(editor.getHTML()).toBe(after); + }); + + it('groups updates in quick succession into one undo step, as typing does', async () => { + const { editor } = await renderPassage({ value }); + const before = editor.getHTML(); + for (const text of ['M', 'Mo', 'Moo', 'Moon']) { + editor.commands.updateInlineChoice('r1', { + options: [ + { id: 'c1', text }, + { id: 'c2', text: 'y' }, + ], + }); + } + editor.commands.undo(); + expect(editor.getHTML()).toBe(before); + }); + + it('clears a correctId whose option was removed', async () => { + const { editor } = await renderPassage({ value }); + editor.commands.updateInlineChoice('r1', { options: [{ id: 'c2', text: 'y' }] }); + expect(findChip(editor, 'r1').node.attrs.correctId).toBeNull(); + }); + + it('keeps a selected chip selected', async () => { + const { editor } = await renderPassage({ value: `

a${chip('r1', [['c1', 'x']])}b

` }); + const { pos } = findChip(editor, 'r1'); + editor.commands.setNodeSelection(pos); + editor.commands.updateInlineChoice('r1', { options: [{ id: 'c1', text: 'y' }] }); + expect(editor.state.selection.toJSON()).toEqual({ type: 'node', anchor: pos }); + }); + + it('gives an added option with no id a fresh one, keeping the other ids', async () => { + const { editor } = await renderPassage({ value }); + editor.commands.updateInlineChoice('r1', { + options: [ + { id: 'c1', text: 'x' }, + { id: '', text: 'z' }, + ], + }); + const { attrs } = findChip(editor, 'r1').node; + expect(attrs.responseIdentifier).toBe('r1'); + expect(attrs.options).toEqual([ + { id: 'c1', text: 'x' }, + { id: expect.stringMatching(/./), text: 'z' }, + ]); + expect(getDropdowns(editor.getHTML())[0].options[1].id).toBe(attrs.options[1].id); + }); + + it('refuses to leave a chip with no options', async () => { + const { editor } = await renderPassage({ value }); + const before = findChip(editor, 'r1').node.attrs; + expect(editor.commands.updateInlineChoice('r1', { options: [] })).toBe(false); + expect(findChip(editor, 'r1').node.attrs).toEqual(before); + }); + + it('keeps the chip ids when a choice id is also used by another chip', async () => { + const { editor } = await renderPassage({ + value: `

${chip('r0', [['c9', '']])} ${chip('r1', [['c1', 'x']])}

`, + }); + const options = [ + { id: 'c1', text: 'x' }, + { id: 'c9', text: '' }, + ]; + editor.commands.updateInlineChoice('r1', { options }); + expect(findChip(editor, 'r1').node.attrs.options).toEqual(options); + }); + + const deleteAndUndo = editor => { + const { attrs } = findChip(editor, 'r1').node; + editor.view.dispatch(closeHistory(editor.state.tr)); + editor.commands.setNodeSelection(findChip(editor, 'r1').pos); + editor.commands.deleteSelection(); + editor.view.dispatch(closeHistory(editor.state.tr)); + editor.commands.undo(); + expect(findChip(editor, 'r1').node.attrs).toEqual(attrs); + }; + + it('lets undo restore a deleted chip holding an option with no id', async () => { + const { editor } = await renderPassage({ + value: `

a${chip('r1', [ + ['c1', 'x'], + ['', ''], + ])}b

`, + }); + deleteAndUndo(editor); + }); + + it('lets undo restore a deleted chip sharing a choice id with another chip', async () => { + const { editor } = await renderPassage({ + value: `

a${chip('r1', [['c1', 'x']])}b${chip('r2', [['c2', 'y']])}

`, + }); + editor.commands.updateInlineChoice('r2', { options: [{ id: 'c1', text: 'y' }] }); + deleteAndUndo(editor); + }); + }); + + describe('paste', () => { + const source = chip( + 'r1', + [ + ['c1', 'x'], + ['c2', 'y'], + ], + 'c2', + ); + + // A copy of `source` with none of its ids, and the same option correct. + const expectFreshCopy = copy => { + expect(copy.responseIdentifier).not.toBe('r1'); + expect(copy.options.map(o => o.id)).not.toContain('c1'); + expect(copy.options.map(o => o.id)).not.toContain('c2'); + expect(copy.correctId).toBe(copy.options[1].id); + }; + + it('gives a copy fresh ids and keeps the same option correct', async () => { + const { editor } = await renderPassage({ value: `

${source}

` }); + paste(editor, source); + + const dropdowns = getDropdowns(editor.getHTML()); + expect(dropdowns).toHaveLength(2); + const [original, copy] = dropdowns; + expect(copy.responseIdentifier).not.toBe(original.responseIdentifier); + expect(copy.options.map(o => o.text)).toEqual(['x', 'y']); + copy.options.forEach((o, i) => expect(o.id).not.toBe(original.options[i].id)); + expect(copy.correctId).toBe(copy.options[1].id); + }); + + it('renames only the choice id that collides', async () => { + const { editor } = await renderPassage({ value: `

${source}

` }); + paste(editor, chip('other', [['c1', 'z']], 'c1')); + const dropdowns = getDropdowns(editor.getHTML()); + expect(dropdowns).toHaveLength(2); + const copy = dropdowns.find(d => d.options[0].text === 'z'); + expect(copy.responseIdentifier).toBe('other'); + expect(copy.options[0].id).not.toBe('c1'); + expect(copy.correctId).toBe(copy.options[0].id); + }); + + it('re-identifies a chip whose own choices share an id', async () => { + const { editor } = await renderPassage({ value: '

a

' }); + paste( + editor, + chip( + 'rX', + [ + ['c1', 'p'], + ['c1', 'q'], + ], + 'c1', + ), + ); + const [{ options, correctId }] = getDropdowns(editor.getHTML()); + expect(options[0].id).not.toBe(options[1].id); + expect(correctId).toBe(options[0].id); + }); + + it('gives a pasted chip missing its ids fresh ones', async () => { + const { editor } = await renderPassage({ value: '

a

' }); + paste( + editor, + 't', + ); + const [pasted] = getDropdowns(editor.getHTML()); + expect(pasted.responseIdentifier).not.toBe(''); + expect(pasted.options).toEqual([{ id: expect.stringMatching(/./), text: 't' }]); + }); + + it('keeps ids when the source was removed first (cut and paste)', async () => { + const { editor } = await renderPassage({ value: `

${source}

` }); + editor.commands.setNodeSelection(findChip(editor, 'r1').pos); + editor.commands.deleteSelection(); + paste(editor, source); + expect(getDropdowns(editor.getHTML())).toEqual([ + { + responseIdentifier: 'r1', + options: [ + { id: 'c1', text: 'x' }, + { id: 'c2', text: 'y' }, + ], + correctId: 'c2', + }, + ]); + }); + + it('keeps ids on cut and paste of a chip holding an option with no id', async () => { + const { editor } = await renderPassage({ + value: `

${chip('r1', [ + ['c1', 'x'], + ['', ''], + ])}

`, + }); + const cut = editor.getHTML(); + editor.commands.setNodeSelection(findChip(editor, 'r1').pos); + editor.commands.deleteSelection(); + paste(editor, cut); + const [{ responseIdentifier, options }] = getDropdowns(editor.getHTML()); + expect(responseIdentifier).toBe('r1'); + expect(options).toEqual([ + { id: 'c1', text: 'x' }, + { id: expect.stringMatching(/./), text: '' }, + ]); + }); + + describe('with the toolbar Copy and Paste buttons', () => { + let clipboard; + + // jsdom has none; ProseMirror's `pasteHTML` constructs one. + beforeAll(() => { + global.ClipboardEvent = class ClipboardEvent extends Event {}; + }); + + afterAll(() => { + delete global.ClipboardEvent; + }); + + beforeEach(() => { + clipboard = []; + global.ClipboardItem = class { + constructor(data) { + this.types = Object.keys(data); + // jsdom's Blob has no `text()`. + this.getType = type => + Promise.resolve({ + text: () => + new Promise(resolve => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.readAsText(data[type]); + }), + }); + } + }; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + write: items => Promise.resolve((clipboard = items)), + read: () => Promise.resolve(clipboard), + }, + }); + }); + + afterEach(() => { + delete global.ClipboardItem; + delete navigator.clipboard; + }); + + const clickPaste = async (editor, html) => { + if (html) { + clipboard = [new ClipboardItem({ 'text/html': new Blob([html]) })]; + } + editor.commands.focus('end'); + await userEvent.click(screen.getByRole('button', { name: paste$() })); + }; + + it('gives a pasted copy fresh ids', async () => { + const { editor } = await renderPassage({ value: `

a${source}b

` }); + await clickPaste(editor, source); + await waitFor(() => expect(getDropdowns(editor.getHTML())).toHaveLength(2)); + const [original, copy] = getDropdowns(editor.getHTML()); + expect(original.responseIdentifier).toBe('r1'); + expectFreshCopy(copy); + }); + + it('copies a selected chip as a dropdown that pastes as a new chip', async () => { + const { editor } = await renderPassage({ value: `

a${source}b

` }); + editor.commands.focus(); + editor.commands.setNodeSelection(findChip(editor, 'r1').pos); + await userEvent.click(screen.getByRole('button', { name: copy$() })); + await waitFor(() => expect(clipboard).toHaveLength(1)); + + await clickPaste(editor); + await waitFor(() => expect(getDropdowns(editor.getHTML())).toHaveLength(2)); + const [, copy] = getDropdowns(editor.getHTML()); + expect(copy.options.map(o => o.text)).toEqual(['x', 'y']); + expectFreshCopy(copy); + }); + }); + + describe('inserted by a transaction', () => { + it('gives a copy of a chip still in the passage fresh ids', async () => { + const { editor } = await renderPassage({ value: `

a${source}b

` }); + const { node } = findChip(editor, 'r1'); + editor.view.dispatch(editor.state.tr.insert(1, node.type.create(node.attrs))); + + const [copy, original] = getDropdowns(editor.getHTML()); + expect(original).toEqual(node.attrs); + expectFreshCopy(copy); + expect(copy.options.map(o => o.text)).toEqual(['x', 'y']); + }); + + // An option with no id would make a newly arrived chip take fresh ids. + it('keeps the ids of a chip deleted and reinserted elsewhere', async () => { + const moved = chip( + 'r1', + [ + ['c1', 'x'], + ['', 'y'], + ], + 'c1', + ); + const { editor } = await renderPassage({ value: `

a${moved}b

z

` }); + const { node, pos } = findChip(editor, 'r1'); + const tr = editor.state.tr.delete(pos, pos + node.nodeSize); + tr.insert(tr.doc.content.size - 1, node); + editor.view.dispatch(tr); + + expect(editor.getHTML()).toBe(`

ab

z${moved}

`); + }); + }); + + // ProseMirror reads the copy modifier (Ctrl off macOS) again at drop, so it may differ from + // dragstart. + const drag = async ({ copyAtStart, copyAtDrop }) => { + const { editor } = await renderPassage({ value: `

a${source}b

z

` }); + const { view } = editor; + editor.commands.setNodeSelection(findChip(editor, 'r1').pos); + const data = {}; + const dataTransfer = { + files: [], + clearData: () => {}, + setData: (type, value) => (data[type] = value), + getData: type => data[type] || '', + }; + const fire = (type, ctrlKey) => { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, ctrlKey }); + Object.defineProperty(event, 'dataTransfer', { value: dataTransfer }); + view.dom.dispatchEvent(event); + }; + // jsdom has no layout; drop at the end of the second paragraph. + const dropPos = editor.state.doc.content.size - 1; + view.posAtCoords = () => ({ pos: dropPos, inside: -1 }); + fire('dragstart', copyAtStart); + fire('drop', copyAtDrop); + return getDropdowns(editor.getHTML()); + }; + + it.each([ + ['no modifier', false, false], + ['the modifier released before drop', true, false], + ])('keeps ids when a chip is drag-moved with %s', async (_, copyAtStart, copyAtDrop) => { + const dropdowns = await drag({ copyAtStart, copyAtDrop }); + expect(dropdowns).toHaveLength(1); + expect(dropdowns[0].responseIdentifier).toBe('r1'); + expect(dropdowns[0].options.map(o => o.id)).toEqual(['c1', 'c2']); + }); + + it.each([ + ['the modifier', true, true], + ['the modifier pressed only at drop', false, true], + ])('gives fresh ids when a chip is drag-copied with %s', async (_, copyAtStart, copyAtDrop) => { + const [original, copy] = await drag({ copyAtStart, copyAtDrop }); + expect(original.responseIdentifier).toBe('r1'); + expectFreshCopy(copy); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js new file mode 100644 index 0000000000..5967a48456 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js @@ -0,0 +1,79 @@ +/* eslint-env jest */ +import { ref } from 'vue'; +import { render, waitFor } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import { findChip as findChipIn } from '../InlineChoiceNode'; +import { injectPassageChips } from '../passageChips'; +import { useInlineChoicePassage } from '../useInlineChoicePassage'; +import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; + +/** Query options matching a chip's accessible name in any state. */ +export const CHIP = { + name: /, answer dropdown, /, +}; + +/** Dropdown HTML; `options` are `[id, text]` pairs. */ +export const chip = (id, options, correct) => + `${options + .map( + ([optionId, text]) => + `${text}`, + ) + .join('')}`; + +/** `{ node, pos }` of the chip with that response identifier, or `null`. */ +export const findChip = (editor, responseIdentifier) => + findChipIn(editor.state.doc, responseIdentifier) || null; + +/** Mounts the real TipTapEditor through `useInlineChoicePassage`; resolves once it is ready. */ +export async function renderPassage({ value, errorResponseIdentifiers, mode = 'edit' }) { + let passage; + let provided; + const modeRef = ref(mode); + // Reads what the passage provides, as the options panel (#6182) does. + const Panel = { + setup() { + provided = injectPassageChips(); + }, + render: h => h(), + }; + const onReady = jest.fn(); + const onMinimize = jest.fn(); + render( + { + components: { TipTapEditor, Panel }, + setup() { + passage = useInlineChoicePassage({ errorResponseIdentifiers }); + return { + extensions: passage.extensions, + insertActions: passage.insertActions, + mode: modeRef, + }; + }, + data: () => ({ value }), + methods: { onReady, onMinimize }, + template: `
`, + }, + { router: new VueRouter() }, + ); + await waitFor(() => expect(onReady).toHaveBeenCalled()); + return { + editor: onReady.mock.calls[0][0], + openResponseIdentifier: passage.openResponseIdentifier, + focusChip: provided.focusChip, + onMinimize, + setMode: newMode => { + modeRef.value = newMode; + }, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js new file mode 100644 index 0000000000..df1e61fbdc --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js @@ -0,0 +1,178 @@ +import { nextTick } from 'vue'; +import { screen, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { qtiEditorStrings } from '../../../qtiEditorStrings'; +import { getDropdowns } from '../parse'; +import { CHIP, chip, renderPassage } from './renderPassage'; +import { stubProseMirrorLayout } from 'shared/utils/testing'; + +// jsdom defines `ontouchstart`, which would put the editor in its touch layout. +jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false })); + +const dropdowns = editor => getDropdowns(editor.getHTML()); + +const chipHTML = ({ responseIdentifier, options, correctId }) => + chip( + responseIdentifier, + options.map(option => [option.id, option.text]), + correctId, + ); + +// The handler only trusts the selection once the author has focused the editor. +async function renderAt(value, from, to = from) { + const rendered = await renderPassage({ value }); + rendered.editor.view.dom.focus(); + rendered.editor.commands.setTextSelection({ from, to }); + await nextTick(); + return rendered; +} + +const ORIGINAL = { + responseIdentifier: 'r1', + options: [{ id: 'a', text: 'x' }], + correctId: 'a', +}; + +const INSERT = { name: qtiEditorStrings.insertInlineChoice$() }; + +const clickInsert = () => userEvent.click(screen.getByRole('button', INSERT)); + +describe('useInlineChoicePassage Insert action', () => { + stubProseMirrorLayout(); + + it('inserts a blank chip at the cursor and opens it', async () => { + const { editor, openResponseIdentifier } = await renderAt('

The rises

', 5); + await clickInsert(); + + expect(dropdowns(editor)).toHaveLength(1); + const [dropdown] = dropdowns(editor); + expect(dropdown.options).toEqual([{ id: expect.any(String), text: '' }]); + expect(dropdown.correctId).toBeNull(); + expect(editor.getHTML()).toBe(`

The ${chipHTML(dropdown)}rises

`); + expect(openResponseIdentifier.value).toBe(dropdown.responseIdentifier); + const el = await screen.findByRole('button', CHIP); + expect(el).toHaveTextContent(`0 ${qtiEditorStrings.addAnswers$()}`); + await waitFor(() => expect(el).toHaveAttribute('aria-expanded', 'true')); + }); + + it.each([ + ['a code block', '

a

xyz
'], + ['an image', '

a

'], + ])( + 'puts the cursor after a chip inserted at the end of a passage ending in %s', + async (_, value) => { + const { editor } = await renderPassage({ value }); + await clickInsert(); + editor.commands.insertContent('Z'); + + const [dropdown] = dropdowns(editor); + expect(editor.getHTML()).toContain(`

${chipHTML(dropdown)}Z

`); + }, + ); + + it('closes the open dropdown when its chip leaves the passage', async () => { + const { editor, openResponseIdentifier } = await renderAt('

The rises

', 5); + await clickInsert(); + expect(openResponseIdentifier.value).not.toBeNull(); + + editor.commands.undo(); + expect(dropdowns(editor)).toEqual([]); + expect(openResponseIdentifier.value).toBeNull(); + }); + + it('turns highlighted text into a chip whose correct option is that text', async () => { + const { editor, openResponseIdentifier } = await renderAt('

The Moon rises

', 5, 9); + await clickInsert(); + + expect(dropdowns(editor)).toHaveLength(1); + const [dropdown] = dropdowns(editor); + expect(dropdown.options).toEqual([{ id: expect.any(String), text: 'Moon' }]); + expect(dropdown.correctId).toBe(dropdown.options[0].id); + expect(editor.getHTML()).toBe(`

The ${chipHTML(dropdown)} rises

`); + expect(openResponseIdentifier.value).toBe(dropdown.responseIdentifier); + await waitFor(() => + expect(screen.getByRole('button', CHIP)).toHaveAttribute('aria-expanded', 'true'), + ); + }); + + it('drops the formatting of highlighted text', async () => { + const { editor } = await renderAt('

Moon

', 1, 5); + await clickInsert(); + + expect(dropdowns(editor)).toHaveLength(1); + const [dropdown] = dropdowns(editor); + expect(dropdown.options[0].text).toBe('Moon'); + expect(editor.getHTML()).toBe(`

${chipHTML(dropdown)}

`); + }); + + it('leaves whitespace around the highlighted text in the passage', async () => { + const { editor } = await renderAt('

The Moon rises

', 4, 10); + await clickInsert(); + + expect(dropdowns(editor)).toHaveLength(1); + const [dropdown] = dropdowns(editor); + expect(dropdown.options[0].text).toBe('Moon'); + expect(editor.getHTML()).toBe(`

The ${chipHTML(dropdown)} rises

`); + }); + + it('turns a one-paragraph passage selected with select-all into a chip', async () => { + const { editor } = await renderAt('

The Moon

', 1); + editor.commands.selectAll(); + await nextTick(); + expect(screen.getByRole('button', INSERT)).not.toHaveAttribute('aria-disabled', 'true'); + await clickInsert(); + + const [dropdown] = dropdowns(editor); + expect(dropdown.options).toEqual([{ id: expect.any(String), text: 'The Moon' }]); + expect(editor.getHTML()).toBe(`

${chipHTML(dropdown)}

`); + }); + + it('is unavailable while the selection spans paragraphs', async () => { + const { editor } = await renderAt('

one

two

', 2, 7); + await clickInsert(); + + expect(screen.getByRole('button', INSERT)).toHaveAttribute('aria-disabled', 'true'); + expect(dropdowns(editor)).toEqual([]); + }); + + it('is unavailable where a chip cannot go', async () => { + const { editor } = await renderAt('
x
', 1); + await clickInsert(); + + expect(screen.getByRole('button', INSERT)).toHaveAttribute('aria-disabled', 'true'); + expect(dropdowns(editor)).toEqual([]); + }); + + it('is available before focus when the passage starts where a chip cannot go', async () => { + const { editor } = await renderPassage({ value: '
x

a

' }); + expect(screen.getByRole('button', INSERT)).not.toHaveAttribute('aria-disabled', 'true'); + await clickInsert(); + + expect(dropdowns(editor)).toHaveLength(1); + }); + + it('adds a new chip after a node-selected chip instead of replacing it', async () => { + const { editor, openResponseIdentifier } = await renderAt(`

a${chipHTML(ORIGINAL)}b

`, 1); + editor.commands.setNodeSelection(2); + await nextTick(); + await clickInsert(); + + expect(dropdowns(editor)).toHaveLength(2); + const [first, second] = dropdowns(editor); + expect(first).toEqual(ORIGINAL); + expect(second.options).toEqual([{ id: expect.any(String), text: '' }]); + expect(editor.getHTML()).toBe(`

a${chipHTML(ORIGINAL)}${chipHTML(second)}b

`); + expect(openResponseIdentifier.value).toBe(second.responseIdentifier); + }); + + it('keeps a chip inside the highlighted text and adds the new one after it', async () => { + const { editor } = await renderAt(`

a ${chipHTML(ORIGINAL)} b

`, 1, 5); + await clickInsert(); + + const [first, second] = dropdowns(editor); + expect(dropdowns(editor)).toHaveLength(2); + expect(first).toEqual(ORIGINAL); + expect(second.options).toEqual([{ id: expect.any(String), text: '' }]); + expect(editor.getHTML()).toBe(`

a ${chipHTML(ORIGINAL)} ${chipHTML(second)}b

`); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue new file mode 100644 index 0000000000..4cdeefad77 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue @@ -0,0 +1,219 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js index 32ebe0611b..aae73f4267 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js @@ -5,11 +5,11 @@ import { generateRandomSlug } from '../../utils/generateRandomSlug'; const serializer = new XMLSerializer(); -const DROPDOWN = 'qti-inline-choice-interaction'; -const OPTION = 'qti-inline-choice'; +export const DROPDOWN = 'qti-inline-choice-interaction'; +export const OPTION = 'qti-inline-choice'; const PROMPT_MARKER = 'data-studio-prompt'; const SENTINEL_MARKER = 'data-studio-sentinel'; -const CORRECT_ATTR = 'data-studio-correct'; +export const CORRECT_ATTR = 'data-studio-correct'; const SENTINEL_ID = 'studio_sentinel'; const KEPT_DROPDOWN_ATTRS = ['response-identifier', CORRECT_ATTR]; const BLOCK_TAGS = new Set([ @@ -63,7 +63,7 @@ export function getDropdowns(passage) { * @param {Element} el - A `` as it is held in the passage * @returns {InlineChoiceDropdown} */ -function readDropdown(el) { +export function readDropdown(el) { const options = [...el.querySelectorAll(OPTION)].map(option => ({ id: option.getAttribute('identifier') ?? '', text: option.textContent, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js new file mode 100644 index 0000000000..2c53312e21 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js @@ -0,0 +1,11 @@ +import { inject, provide } from 'vue'; + +const PassageChipsSymbol = Symbol('inlineChoicePassageChips'); + +export function providePassageChips(chips) { + provide(PassageChipsSymbol, chips); +} + +export function injectPassageChips() { + return inject(PassageChipsSymbol); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js new file mode 100644 index 0000000000..7117a308ce --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js @@ -0,0 +1,95 @@ +import { computed, readonly, ref, unref } from 'vue'; +// The resolver does not read package `exports` subpaths. +// eslint-disable-next-line import/no-unresolved +import { AllSelection } from '@tiptap/pm/state'; +import { qtiEditorStrings } from '../../qtiEditorStrings'; +import { generateRandomSlug } from '../../utils/generateRandomSlug'; +import { InlineChoiceNode, findChip } from './InlineChoiceNode'; +import { providePassageChips } from './passageChips'; + +// Select-all selects the passage itself, where an inline node cannot go. Over a passage of one +// paragraph, it is taken as that paragraph's text. +function paragraphOfAllSelection({ doc, selection, schema }) { + const only = doc.childCount === 1 ? doc.firstChild : null; + const holdsChip = only && only.type.contentMatch.matchType(schema.nodes[InlineChoiceNode.name]); + if (!(selection instanceof AllSelection) || !holdsChip) return null; + return { from: 1, to: doc.content.size - 1 }; +} + +/** + * Everything a passage's `TipTapEditor` needs to hold inline choice dropdowns. Call from the + * setup of the component that renders the editor, so the chips can reach what it provides. + * `openResponseIdentifier` names the chip whose options panel is open, or is `null`. + * `errorResponseIdentifiers` (array or ref) names the dropdowns with validation errors. + */ +export function useInlineChoicePassage({ errorResponseIdentifiers = [] } = {}) { + const openResponseIdentifier = ref(null); + // `setEditable` emits `update`; `editor.isEditable` itself is not reactive. + const isEditable = ref(false); + let editor = null; + + function openDropdown(responseIdentifier) { + openResponseIdentifier.value = responseIdentifier; + } + + function focusChip(responseIdentifier) { + if (!editor) return; + const found = findChip(editor.state.doc, responseIdentifier); + if (found) editor.view.nodeDOM(found.pos).querySelector('button')?.focus(); + else editor.commands.focus(); + } + + const readonlyOpenResponseIdentifier = readonly(openResponseIdentifier); + providePassageChips({ + openResponseIdentifier: readonlyOpenResponseIdentifier, + openDropdown, + focusChip, + isEditable: readonly(isEditable), + errorResponseIdentifiers: computed(() => unref(errorResponseIdentifiers)), + }); + + const node = InlineChoiceNode.extend({ + onCreate() { + editor = this.editor; + isEditable.value = editor.isEditable; + }, + onDestroy() { + editor = null; + }, + onUpdate() { + isEditable.value = this.editor.isEditable; + // Undo, delete or cut can take the open chip out of the passage. + if (openResponseIdentifier.value === null) return; + if (!findChip(this.editor.state.doc, openResponseIdentifier.value)) openDropdown(null); + }, + }); + + const insertAction = { + name: InlineChoiceNode.name, + title: qtiEditorStrings.insertInlineChoice$(), + icon: 'add', + prominent: true, + // Before focus, the chip goes at the end of the passage, which always takes one. + // Replacing a multi-line selection with an inline node would join the lines. + isAvailable: ({ editor, selection, canInsertNode }) => + !selection.hasCursor || + (!selection.spansLines && + (canInsertNode(InlineChoiceNode.name) || Boolean(paragraphOfAllSelection(editor.state)))), + handler: ({ editor, selection }) => { + const responseIdentifier = generateRandomSlug('response'); + const paragraph = paragraphOfAllSelection(editor.state); + let chain = editor.chain().focus(selection.hasCursor ? undefined : 'end'); + if (paragraph) chain = chain.setTextSelection(paragraph); + chain.insertInlineChoice(responseIdentifier).run(); + openDropdown(responseIdentifier); + }, + }; + + return { + extensions: [node], + insertActions: [insertAction], + openResponseIdentifier: readonlyOpenResponseIdentifier, + openDropdown, + focusChip, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 45d78c4b00..5d025cc924 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -507,4 +507,37 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { 'When enabled, learners see a hint below the answer options so they know how many answers to choose. Toggle this off to increase question difficulty.', context: 'Modal body explaining answer count hint', }, + insertInlineChoice: { + message: 'Insert', + context: + 'Button in the passage toolbar that adds an answer dropdown at the cursor, or turns the highlighted text into one', + }, + addAnswers: { + message: 'Add answers', + context: 'Label on an answer dropdown chip that has no correct answer yet', + }, + answerDropdownWithCorrect: { + message: + '{label}, correct answer, answer dropdown, {count, plural, one {# option} other {# options}}', + context: + 'Accessible name of an answer dropdown chip in the passage that has a correct answer. {label} is the correct answer, as the chip shows it', + }, + answerDropdownNoCorrect: { + message: + '{label}, answer dropdown, {count, plural, one {# option} other {# options}}, no correct answer yet', + context: + "Accessible name of an answer dropdown chip in the passage that has no correct answer. {label} is the chip's 'Add answers' label", + }, + answerDropdownWithCorrectNeedsAttention: { + message: + '{label}, correct answer, answer dropdown, needs attention, {count, plural, one {# option} other {# options}}', + context: + 'Accessible name of an answer dropdown chip in the passage that has a correct answer and validation errors. {label} is the correct answer, as the chip shows it', + }, + answerDropdownNoCorrectNeedsAttention: { + message: + '{label}, answer dropdown, needs attention, {count, plural, one {# option} other {# options}}, no correct answer yet', + context: + "Accessible name of an answer dropdown chip in the passage that has no correct answer and has validation errors. {label} is the chip's 'Add answers' label", + }, }); diff --git a/package.json b/package.json index fe7102c9c7..bbbe23c682 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@tiptap/extension-superscript": "^3.31.3", "@tiptap/extension-text-align": "^3.31.3", "@tiptap/extension-underline": "^3.31.3", + "@tiptap/pm": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", "@tiptap/vue-2": "^3.31.3", "ajv": "^8.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e0bf6b7bb..3ad8303946 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@tiptap/extension-underline': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': + specifier: ^3.31.3 + version: 3.31.3 '@tiptap/starter-kit': specifier: ^3.31.3 version: 3.31.3