Skip to content

[QTI] Gap match interaction editor, with the gap node and Insert action #6204

Open

Description

@AlexVelezLl

❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.

Overview

Build the gap match interaction editor and turn the question type on: the gap (the TipTap node for one gap in the passage, with its answer typed straight into it), the Insert action that places it, the distractors list, and the editor card around them. The gap is contributed to TipTapEditor from the QTI editor through the seams added in #6176, and TipTapEditor stays free of QTI vocabulary. It uses the descriptor from #6203 and the inline-only TipTapEditor mode from #6193.

Complexity: High
Target branch: unstable

Context

In the design, a gap match card in edit mode shows, top to bottom:

  1. Type: the question type selector, set to "Gap match"
  2. Question (optional): a rich-text prompt field, like the other interactions'
  3. Passage editor: helper text "Gap widgets can be inserted blank or converted from highlighted text.", then the passage's rich text TipTapEditor, whose toolbar adds Insert as a prominent, labelled button
  4. Distractors (optional): helper text "Extra items shown in the response pool that don't match any prompt", the distractors as removable chips, and an Add distractor button

A gap shows its answer in a solid green chip. A blank gap shows as an empty dashed chip holding the cursor.

In view mode, the card shows the question and, under an Answers heading, the passage with each gap's answer in place.

EditableChipList (from the associate editor) already renders editable, removable chips with an add button, matching the distractors list.

A gap's answer travels with the gap. Undo and redo restore the passage document, and copy / paste moves document content, so the node carries its answer, as the inline choice chip does (#6181).

Adding the gap to TipTapEditor

If you haven't extended TipTapEditor before, start with Extending the editor from a consumer in docs/rich_text_editor.md. The gap is a TipTap node that the gap match editor passes to the passage's TipTapEditor. TipTapEditor itself doesn't change. TipTapEditor/extensions/Math.js is the closest existing node to start from. A rough sketch:

// interactions/gapMatch/extensions/Gap.js: the node
import { Node, mergeAttributes } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import { generateRandomSlug } from '../../../utils/generateRandomSlug';
import GapNodeView from './GapNodeView.vue';

export const Gap = Node.create({
  name: 'gap',
  group: 'inline',
  inline: true,
  // The answer is the gap's content, so undo and copy / paste keep it.
  // Text and math only: no line break, image or nested gap can go in (#6193).
  content: '(text | math)*',

  addAttributes() {
    return { identifier: { default: null } };
  },

  // Use the passage markup agreed in #6203.
  parseHTML() {
    return [{ tag: 'qti-gap' }];
  },
  renderHTML({ HTMLAttributes }) {
    return ['qti-gap', mergeAttributes(HTMLAttributes), 0]; // 0: the answer goes here
  },

  // The chip: solid with an answer, dashed when blank. It renders <NodeViewContent>
  // where the answer is typed.
  addNodeView() {
    return VueNodeViewRenderer(GapNodeView);
  },

  addCommands() {
    return {
      insertGap:
        answer =>
        ({ commands }) =>
          commands.insertContent({
            type: this.name,
            attrs: { identifier: generateRandomSlug('gap') },
            content: answer,
          }),
    };
  },

  // TODO: addProseMirrorPlugins() with a `transformPasted` that gives each pasted gap
  // a fresh identifier.
});
// interactions/gapMatch/Editor.vue, in setup(): passed to the passage editor only
const passageExtensions = [Gap]; // read once, when the editor is created

const insertGapAction = {
  name: 'gap',
  title: insertGap$(), // from qtiEditorStrings
  icon: 'add',
  prominent: true,
  isAvailable: ({ selection, canInsertNode }) => !selection.spansLines && canInsertNode('gap'),
  handler: ({ editor, selection }) => {
    // Highlighted text becomes the answer; otherwise the gap starts blank.
    const { from, to } = editor.state.selection;
    const answer = selection.empty ? undefined : editor.state.doc.slice(from, to).content.toJSON();
    (selection.hasCursor ? editor.chain().focus() : editor.chain().focus('end'))
      .insertGap(answer)
      .run();
  },
};
<!-- format="html": the markdown serializer drops nodes it doesn't know -->
<TipTapEditor
  format="html"
  :value="state.passage"
  :extensions="passageExtensions"
  :insertActions="[insertGapAction]"
  @update="setPassage"
/>

The Change

Gap node and Insert

  • A gap node and node view under interactions/gapMatch/, registered on the passage's TipTapEditor through the extensions prop. The node holds its gap identifier and its answer, and parses from and renders to the passage markup [QTI] Gap match interaction: descriptor, parsing, XML assembly and validation #6203 defines, so passage HTML round-trips through the editor without losing a gap or its answer.
  • Typing inside a gap edits its answer. The answer accepts only what <qti-gap-text> allows, the same set as the inline-only mode in [QTI] Add an inline-only mode to TipTapEditor for gap match choices #6193 (inline formatting and math; no blocks, images or links).
  • An Insert action contributed through insertActions, with the prominent (labelled) desktop treatment:
    • with no cursor in the passage, it adds a blank gap at the end of the passage, with the cursor inside it
    • with a cursor, it adds a blank gap at the cursor, with the cursor inside it
    • with text highlighted within one line, it replaces that text with a gap whose answer is that text, keeping its inline formatting and math and dropping anything a <qti-gap-text> cannot hold
    • it is disabled when the selection spans more than one line (selection.spansLines), or where the node cannot be inserted (canInsertNode)
  • A pasted or duplicated gap gets a fresh gap identifier (in transformPasted), so an item never carries two gaps with the same identifier.

Editor

  • interactions/gapMatch/Editor.vue and a useGapMatchInteraction composable built on useInteraction, registered in interactions/index.js.
  • The descriptor offers Gap match in the question type selector, and the card's question type label shows it.
  • The question field, the passage editor and the distractors list follow the layout above. Distractors are added (Add distractor), edited and removed as chips, each edited in the inline-only mode ([QTI] Add an inline-only mode to TipTapEditor for gap match choices #6193).
  • Validation errors from the descriptor are shown on the gap or distractor they belong to, and mark the question as incomplete, as in the other interactions.
  • View mode shows the question and the passage with its gaps. Whether the gaps show their answers follows Show answers, as the other interactions do.

Out of Scope

Acceptance Criteria

General

  • Gap match is offered in the question type selector, and switching an item to it shows the question field, the passage editor, the distractors list and their helper text
  • Insert with no cursor in the passage adds a blank gap at the end of the passage, with the cursor inside it
  • Insert with a cursor adds a blank gap at the cursor, with the cursor inside it
  • Insert over highlighted text within one line replaces it with a gap whose answer is that text, keeping its inline formatting
  • Insert is disabled when the selection spans more than one line
  • Typing inside a gap edits its answer, and blocks, images and links cannot be added to it by toolbar, shortcut or paste
  • A gap with an answer shows as a solid chip, and a blank gap as a dashed one
  • Undoing the deletion of a gap restores it with its answer
  • Copying and pasting a gap produces a second gap with the same answer and a different gap identifier
  • Distractors can be added, edited and removed
  • Saving and reopening an item restores the question, the passage, every gap's answer and the distractors
  • Each validation case from the descriptor is shown on the gap or distractor it belongs to, and marks the question as incomplete
  • View mode shows the question and the passage with its gaps, and follows Show answers for the answers
  • No file under shared/views/TipTapEditor imports from or refers to the QTI editor

Accessibility and i18n

  • Insert is part of the toolbar's roving tabindex and is keyboard-operable
  • A gap can be reached, and its answer edited, with the keyboard
  • A gap has an accessible name that says it is a gap, with its position and its answer, or that it is empty
  • Distractor chips can be added, edited and removed by keyboard, with accessible labels
  • All new user-visible strings go through qtiEditorStrings
  • The editor works in RTL and at mobile widths

Testing

  • Tests cover each Insert path and its being disabled across lines
  • Tests cover undo after deleting a gap, and paste giving a fresh gap identifier
  • A test covers the gap's accessible name in each state
  • VTL tests cover editing distractors, and that saving and reopening restores every gap's answer and the distractors

Testing

pnpm jest --config jest_config/jest.conf.js contentcuration/contentcuration/frontend/shared/views/QTIEditor

To check end-to-end, publish a channel with a gap match question and, once kolibri#15213 lands, open it in Kolibri's QTI viewer.

References

AI usage

I used Claude Code to draft this issue from the Figma design, #6176, #6193 and the existing interaction editors and TipTap extensions. I decided how to split the work and reviewed and edited the text.

Activity

  1. added theissue type on Sep 29, 2026
  2. akolson commented on Oct 20, 2026

    @akolson
    Member

    Issue #6203 is resolved by #6323.

    In state, each gap keeps its answer text inside the gap node, with the answer identifier stored in a data-studio-answer attribute alongside identifier:

    <qti-gap identifier="gap_a1b2c3d4" data-studio-answer="choice_w1n7erxx">winter</qti-gap>

    insertGap must set this attribute when creating a gap, for example, using generateRandomSlug('choice').

    useInteraction builds from the live state and does not parse it again. Without the attribute, buildXML generates a new random answer identifier on every rebuild. The identifier only stabilizes after the item is saved and reopened.

    getGaps(passage) in interactions/gapMatch/parse.js reads the gaps back in passage order.

  3. akolson commented on Oct 20, 2026

    @akolson
    Member

    Update to my earlier note: #6323 now derives the identifiers that buildXML used to generate, so the gap node no longer has to set data-studio-answer. An answer without one takes its identifier from its gap, for example choice_a1b2c3d4 for gap_a1b2c3d4, and stays the same on every rebuild. The node should still keep the attribute when it is there, so an answer keeps its identifier through edits.

  4. akolson commented on Oct 22, 2026

    @akolson
    Member

    Another update: #6323 renamed the gap attribute from data-studio-answer to data-studio-correct, to match inline choice. My two notes above still apply with the new name.

  5. AlexVelezLl commented on Oct 22, 2026

    @AlexVelezLl
    MemberAuthor

    Implemented in #6323

  6. AlexVelezLl commented on Oct 22, 2026

    @AlexVelezLl
    MemberAuthor

    Whoops, just confused the issues 😅. Reopening

  7. self-assigned this
    on Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions