Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
659 changes: 191 additions & 468 deletions bun.lock

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -37,29 +37,44 @@
// Track cleanup functions for event listeners to prevent memory leaks
let cleanupFunctions: (() => void)[] = [];

function handleGapChange(gapId: string, wordId: string) {
// Remove any existing pair for this gap
const newPairs = pairs.filter((p: string) => !p.endsWith(` ${gapId}`));
function getMatchMax(wordId: string): number {
const gt = parsedInteraction?.gapTexts?.find((g) => g.identifier === wordId);
return gt?.matchMax ?? 1;
}

// If this word is already used in another gap, move it (remove its previous assignment).
const withoutWord = newPairs.filter((p: string) => !p.startsWith(`${wordId} `));
function handleGapChange(gapId: string, wordId: string) {
// Remove any existing pair for this gap (each gap holds one word)
let newPairs = pairs.filter((p: string) => !p.endsWith(` ${gapId}`));

// For reusable words (matchMax=0 or >1): don't remove other placements. For matchMax=1: move the word.
const matchMax = getMatchMax(wordId);
const currentCount = newPairs.filter((p: string) => p.startsWith(`${wordId} `)).length;
if (matchMax === 1 || (matchMax > 0 && currentCount >= matchMax)) {
// Single-use or at limit: remove this word from other gaps (move)
newPairs = newPairs.filter((p: string) => !p.startsWith(`${wordId} `));
}

// Add new pair if a word was selected
if (wordId) {
withoutWord.push(`${wordId} ${gapId}`);
const newPair = `${wordId} ${gapId}`;
if (!newPairs.includes(newPair)) {
newPairs.push(newPair);
}
}

response = withoutWord;
response = newPairs;
// Call onChange callback if provided (for Svelte component usage)
onChange?.(withoutWord);
onChange?.(newPairs);
// Dispatch event for web component usage - event will bubble up to the host element
if (rootElement) {
rootElement.dispatchEvent(createQtiChangeEvent(parsedInteraction?.responseId, withoutWord));
rootElement.dispatchEvent(createQtiChangeEvent(parsedInteraction?.responseId, newPairs));
}
}

function isWordUsed(wordId: string): boolean {
return pairs.some((p: string) => p.startsWith(wordId));
const matchMax = getMatchMax(wordId);
const count = pairs.filter((p: string) => p.startsWith(`${wordId} `)).length;
return matchMax > 0 ? count >= matchMax : false; // matchMax=0 means unlimited, never "used"
}

function getSelectedWord(gapId: string): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,19 @@ let hoveredHotspotId = $state<string | null>(null);
let keyboardSelectedTextId = $state<string | null>(null); // Gap text selected via keyboard
let announceText = $state<string>(''); // For screen reader announcements

// Get reference to the root element for event dispatching (needed for Shadow DOM)
let rootElement: HTMLDivElement | undefined = $state();

// Track if we're updating from internal change (user drag) vs external (prop update)
let isInternalUpdate = false;

$effect(() => {
// Sync with parent response changes
pairs = Array.isArray(parsedResponse) ? [...parsedResponse] : [];
// Sync with parent response changes (only if not an internal update)
if (!isInternalUpdate) {
const newPairs = Array.isArray(parsedResponse) ? [...parsedResponse] : [];
pairs = newPairs;
}
isInternalUpdate = false; // Reset flag
});

// Get the hotspot matched to a gap text
Expand Down Expand Up @@ -100,7 +110,7 @@ function handleHotspotDragLeave() {
}

function handleHotspotDrop(event: DragEvent, hotspotId: string) {
if (disabled || !draggedTextId) return;
if (disabled || !draggedTextId || !parsedInteraction) return;
event.preventDefault();

// Remove any existing pair for this gapText
Expand All @@ -112,21 +122,16 @@ function handleHotspotDrop(event: DragEvent, hotspotId: string) {
// Add new pair
newPairs.push(`${draggedTextId} ${hotspotId}`);

isInternalUpdate = true; // Mark as internal update to prevent sync effect from overwriting
pairs = newPairs;
response = pairs;
// Call onChange callback if provided (for Svelte component usage)
onChange?.(pairs);
// Dispatch custom event for web component usage
const event2 = new CustomEvent('qti-change', {
detail: {
responseId: parsedInteraction?.responseId,
value: pairs,
timestamp: Date.now(),
},
bubbles: true,
composed: true,
});
dispatchEvent(event2);
// Dispatch custom event for web component usage - dispatch from rootElement to ensure it bubbles out of Shadow DOM
const valueArray = Array.isArray(pairs) ? [...pairs] : [];
if (rootElement) {
rootElement.dispatchEvent(createQtiChangeEvent(parsedInteraction.responseId, valueArray));
}

draggedTextId = null;
hoveredHotspotId = null;
Expand All @@ -138,21 +143,16 @@ function clearMatch(gapTextId: string) {
const gapTextName = gapTextObj?.text || 'Label';

const newPairs = pairs.filter((p) => !p.startsWith(`${gapTextId} `));
isInternalUpdate = true; // Mark as internal update
pairs = newPairs;
response = pairs;
// Call onChange callback if provided (for Svelte component usage)
onChange?.(pairs);
// Dispatch custom event for web component usage
const event = new CustomEvent('qti-change', {
detail: {
responseId: parsedInteraction?.responseId,
value: pairs,
timestamp: Date.now(),
},
bubbles: true,
composed: true,
});
dispatchEvent(event);
// Dispatch custom event for web component usage - dispatch from rootElement to ensure it bubbles out of Shadow DOM
const valueArray = Array.isArray(pairs) ? [...pairs] : [];
if (rootElement) {
rootElement.dispatchEvent(createQtiChangeEvent(parsedInteraction.responseId, valueArray));
}

announceText = `${gapTextName} removed from hotspot`;
}
Expand Down Expand Up @@ -213,21 +213,16 @@ function placeSelectedLabelOnHotspot(hotspotId: string) {
newPairs = newPairs.filter((p) => !p.endsWith(` ${hotspotId}`));
newPairs.push(`${keyboardSelectedTextId} ${hotspotId}`);

isInternalUpdate = true; // Mark as internal update
pairs = newPairs;
response = pairs;
// Call onChange callback if provided (for Svelte component usage)
onChange?.(pairs);
// Dispatch custom event for web component usage
const event2 = new CustomEvent('qti-change', {
detail: {
responseId: parsedInteraction?.responseId,
value: pairs,
timestamp: Date.now(),
},
bubbles: true,
composed: true,
});
dispatchEvent(event2);
// Dispatch custom event for web component usage - dispatch from rootElement to ensure it bubbles out of Shadow DOM
const valueArray = Array.isArray(pairs) ? [...pairs] : [];
if (rootElement) {
rootElement.dispatchEvent(createQtiChangeEvent(parsedInteraction.responseId, valueArray));
}

announceText = `${gapTextName} placed on hotspot ${hotspotIndex + 1}`;
keyboardSelectedTextId = null;
Expand Down Expand Up @@ -260,7 +255,7 @@ function parseCoords(hotspot: { identifier: string; shape: string; coords: strin

<ShadowBaseStyles />

<div class="qti-graphic-gap-match-interaction">
<div bind:this={rootElement} class="qti-graphic-gap-match-interaction">
{#if !parsedInteraction}
<div class="alert alert-error">{i18n?.t('common.errorNoData', 'No interaction data provided')}</div>
{:else}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@
<div part="footer" class="qti-hottext-footer flex items-center justify-between text-sm text-base-content/70">
<div>
<span class="font-medium">Selected:</span>
<span class="ml-2">{selectedIds.length} / {parsedInteraction.maxChoices}</span>
<span class="ml-2">{selectedIds.length} / {parsedInteraction.hottextChoices.length}</span>
</div>

{#if selectedIds.length > 0}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@
</div>
{/if}

{#if parsedInteraction.maxPlays > 0}
{#if parsedInteraction.minPlays > 0 && !hasMetMinPlays}
<div>
<span class="font-medium">Remaining:</span>
<span class="ml-2">{Math.max(0, parsedInteraction.maxPlays - playCount)}</span>
<span class="ml-2">{Math.max(0, parsedInteraction.minPlays - playCount)}</span>
</div>
{/if}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,6 @@
// QTI baseType="point" expects array of strings like ["158 168", "250 200"]
const responseValue = positions.map(pos => `${Math.round(pos.x)} ${Math.round(pos.y)}`);

console.log('[PositionObject] Emitting response:', {
responseId: parsedInteraction?.responseId,
value: responseValue,
positions: positions
});

response = responseValue;
// Call onChange callback if provided (for Svelte component usage)
onChange?.(responseValue);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,20 +92,6 @@
: []
);

// Debug logging
$effect(() => {
if (role === 'scorer') {
console.log('[SelectPoint] Role:', role);
console.log('[SelectPoint] parsedCorrectResponse:', parsedCorrectResponse);
console.log('[SelectPoint] correctPoints array:', JSON.stringify(correctPoints));
console.log('[SelectPoint] correctPoints.length:', correctPoints.length);
if (correctPoints.length > 0) {
console.log('[SelectPoint] First correct point x:', correctPoints[0].x, 'y:', correctPoints[0].y);
}
}
});


$effect(() => {
// Sync with parent response changes
const r = parsedResponse;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
<div part="root" class="qti-custom-fallback space-y-3">
<div part="warning" class="qti-custom-warning alert alert-warning">
<div>
<div class="font-semibold">{i18n?.t('interactions.custom.unsupported') ?? 'Unsupported customInteraction'}</div>
<div class="font-semibold">{i18n?.t('interactions.custom.unsupported') ?? 'Custom Interaction (Currently Unsupported)'}</div>
<div class="text-sm">
This item contains a vendor-specific interaction. This player does not execute custom interactions.
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,31 @@
}

function commitValue() {
if (!canvasEl) return;
if (!canvasEl || !ctx) return;
const dataUrl = canvasEl.toDataURL('image/png');

// Extract ImageData synchronously from canvas and include it in the response
// This allows custom operators to analyze drawing content without async image loading
let canvasImageData: globalThis.ImageData | undefined;
try {
canvasImageData = ctx.getImageData(0, 0, canvasEl.width, canvasEl.height);
} catch (e) {
console.warn('[DrawingCanvas] Failed to extract image data:', e);
}

const size = dataUrlSize(dataUrl);
const response: QTIFileResponse = {
name: `drawing-${responseId}.png`,
type: 'image/png',
size,
lastModified: Date.now(),
dataUrl,
// Include ImageData if available (for drawing analysis in custom operators)
imageData: canvasImageData ? {
data: canvasImageData.data,
width: canvasImageData.width,
height: canvasImageData.height,
} : undefined,
};
onChange(response);
announceText = translations.updated;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import type { AssociableChoice } from '@pie-qti/item-player';
import type { I18nProvider } from '@pie-qti/i18n';
import { createOrUpdatePair, getSourceForTarget, getTargetForSource, removePairBySource } from '../utils/pairHelpers.js';
import { createOrUpdatePair, getSourceForTarget, getTargetsForSource, removePairBySource } from '../utils/pairHelpers.js';
import { touchDrag } from '../utils/touchDragHelper.js';
import DragHandle from './DragHandle.svelte';
import '../styles/shared.css';
Expand Down Expand Up @@ -136,63 +136,64 @@ function clearMatch(sourceId: string) {
{i18n?.t('interactions.match.dragFromHere') ?? 'Drag from here:'}
</h3>
{#each sourceSet as source (source.identifier)}
{@const matchedTarget = getTargetForSource(pairs, source.identifier)}
{@const targetItem = matchedTarget ? targetSet.find((t) => t.identifier === matchedTarget) : null}
{@const matchedTargets = getTargetsForSource(pairs, source.identifier)}
{@const targetItems = matchedTargets.map((tid) => targetSet.find((t) => t.identifier === tid)).filter(Boolean)}
{@const isSelected = keyboardSelectedSourceId === source.identifier}
{@const correctTarget = getTargetForSource(correctPairs, source.identifier)}
{@const isCorrect = correctTarget !== null}
{@const correctTargets = getTargetsForSource(correctPairs, source.identifier)}
{@const isCorrect = correctTargets.length > 0}
{@const canDragMore = matchedTargets.length < (source.matchMax ?? 1)}

<div class="qti-match-source-wrapper relative">
<button
type="button"
draggable={!disabled && !matchedTarget}
draggable={!disabled && canDragMore}
use:touchDrag
ondragstart={() => handleDragStart(source.identifier)}
ondragend={handleDragEnd}
onkeydown={(e) => handleSourceKeyDown(e, source.identifier)}
disabled={disabled}
aria-label="{source.text}{matchedTarget && targetItem ? '. Matched with ' + targetItem.text : ''}{isSelected ? '. Selected for matching' : ''}{isCorrect ? '. Correct answer' : ''}"
aria-label="{source.text}{targetItems.length ? '. Matched with ' + targetItems.map((t) => t?.text).join(', ') : ''}{isSelected ? '. Selected for matching' : ''}{isCorrect ? '. Correct answer' : ''}"
aria-pressed={isSelected}
data-matched={!!matchedTarget}
data-matched={matchedTargets.length > 0}
data-selected={isSelected}
data-correct={isCorrect}
data-dragging={draggedSourceId === source.identifier}
part="source-item"
class="qti-match-source p-3 rounded-lg border-2 transition-all w-full"
class:bg-base-200={!matchedTarget && !isSelected && !isCorrect}
class:bg-success={matchedTarget || isCorrect}
class:bg-base-200={matchedTargets.length === 0 && !isSelected && !isCorrect}
class:bg-success={matchedTargets.length > 0 || isCorrect}
class:bg-primary={isSelected}
class:bg-opacity-20={matchedTarget || isSelected || isCorrect}
class:border-base-300={!matchedTarget && !isSelected && !isCorrect}
class:border-success={matchedTarget || isCorrect}
class:bg-opacity-20={matchedTargets.length > 0 || isSelected || isCorrect}
class:border-base-300={matchedTargets.length === 0 && !isSelected && !isCorrect}
class:border-success={matchedTargets.length > 0 || isCorrect}
class:border-primary={isSelected}
class:ring-2={isSelected}
class:ring-primary={isSelected}
class:cursor-grab={!disabled && !matchedTarget && !isSelected}
class:cursor-grab={!disabled && canDragMore && !isSelected}
class:cursor-not-allowed={disabled}
class:opacity-50={disabled || draggedSourceId === source.identifier}
>
<div class="qti-match-source-content flex items-center justify-between gap-2">
<div class="qti-match-source-text flex-1">
<div class="qti-match-source-title font-medium">{source.text}</div>
{#if matchedTarget && targetItem}
<div class="qti-match-source-sub text-sm text-success mt-1">→ {targetItem.text}</div>
{:else if isCorrect && correctTarget}
{@const correctTargetItem = targetSet.find((t) => t.identifier === correctTarget)}
{#if correctTargetItem}
<div class="qti-match-source-sub text-sm text-success mt-1">→ {correctTargetItem.text}</div>
{#if targetItems.length > 0}
<div class="qti-match-source-sub text-sm text-success mt-1">→ {targetItems.map((t) => t?.text).join(', ')}</div>
{:else if isCorrect && correctTargets.length > 0}
{@const correctTargetItems = correctTargets.map((tid) => targetSet.find((t) => t.identifier === tid)).filter(Boolean)}
{#if correctTargetItems.length > 0}
<div class="qti-match-source-sub text-sm text-success mt-1">→ {correctTargetItems.map((t) => t?.text).join(', ')}</div>
{/if}
{/if}
{#if isCorrect && !matchedTarget}
{#if isCorrect && matchedTargets.length === 0}
<span class="badge badge-success badge-sm ml-2">{i18n?.t('interactions.choice.correct', 'Correct') ?? 'Correct'}</span>
{/if}
</div>
{#if !disabled && !matchedTarget}
{#if !disabled && canDragMore}
<DragHandle size={1.25} opacity={0.3} class="text-base-content" />
{/if}
</div>
</button>
{#if matchedTarget && !disabled}
{#if matchedTargets.length > 0 && !disabled}
<button
type="button"
part="source-clear"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import type { MathfieldElement } from 'mathlive';
// Import MathLive CSS from the local package
import 'mathlive/mathlive-static.css';
import 'mathlive/static.css';

// Dynamically import MathLive to register the custom element
let mathLiveLoaded: Promise<void> | null = null;
Expand Down
Loading