@pie-players/pie-tool-answer-eliminator 0.2.6 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"vite.config.d.ts","sourceRoot":"","sources":["../vite.config.ts"],"names":[],"mappings":";AAKA,wBAmCG"}
1
+ {"version":3,"file":"vite.config.d.ts","sourceRoot":"","sources":["../vite.config.ts"],"names":[],"mappings":";AAKA,wBAkCG"}
package/index.ts CHANGED
@@ -6,6 +6,6 @@
6
6
  */
7
7
 
8
8
  // Re-export AdapterRegistry for auto-hide detection
9
- export { AdapterRegistry } from "./adapters/adapter-registry";
9
+ export { AdapterRegistry } from "./adapters/adapter-registry.js";
10
10
 
11
11
  // Re-export any TypeScript types defined in the package
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@pie-players/pie-tool-answer-eliminator",
3
- "version": "0.2.6",
3
+ "version": "0.2.9",
4
4
  "type": "module",
5
5
  "description": "Answer eliminator tool for PIE assessment player - supports process-of-elimination test-taking strategy",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/pie-framework/pie-players.git"
8
+ "url": "git+https://github.com/pie-framework/pie-players.git",
9
+ "directory": "packages/tool-answer-eliminator"
9
10
  },
10
11
  "publishConfig": {
11
12
  "access": "public"
@@ -48,9 +49,9 @@
48
49
  "unpkg": "./dist/tool-answer-eliminator.js",
49
50
  "jsdelivr": "./dist/tool-answer-eliminator.js",
50
51
  "dependencies": {
51
- "@pie-players/pie-assessment-toolkit": "0.2.5",
52
- "@pie-players/pie-players-shared": "0.2.2",
53
- "@sveltejs/kit": "^2.52.0",
52
+ "@pie-players/pie-assessment-toolkit": "0.2.9",
53
+ "@pie-players/pie-context": "0.1.1",
54
+ "@pie-players/pie-players-shared": "0.2.5",
54
55
  "daisyui": "^5.5.18"
55
56
  },
56
57
  "types": "./dist/index.d.ts",
@@ -67,5 +68,13 @@
67
68
  "typescript": "^5.7.0",
68
69
  "vite": "^7.0.8",
69
70
  "vite-plugin-dts": "^4.5.3"
70
- }
71
+ },
72
+ "homepage": "https://github.com/pie-framework/pie-players/tree/master/packages/tool-answer-eliminator#readme",
73
+ "bugs": {
74
+ "url": "https://github.com/pie-framework/pie-players/issues"
75
+ },
76
+ "engines": {
77
+ "node": ">=18.0.0"
78
+ },
79
+ "sideEffects": true
71
80
  }
@@ -1,4 +1,4 @@
1
- import type { EliminationStrategy } from "./elimination-strategy";
1
+ import type { EliminationStrategy } from "./elimination-strategy.js";
2
2
 
3
3
  /**
4
4
  * Mask strategy using CSS Custom Highlight API
@@ -9,6 +9,7 @@ export class MaskStrategy implements EliminationStrategy {
9
9
 
10
10
  private highlights = new Map<string, Highlight>();
11
11
  private ranges = new Map<string, Range>();
12
+ private fallbackContainers = new Map<string, HTMLElement>();
12
13
 
13
14
  initialize(): void {
14
15
  this.injectCSS();
@@ -55,6 +56,7 @@ export class MaskStrategy implements EliminationStrategy {
55
56
 
56
57
  this.highlights.delete(choiceId);
57
58
  this.ranges.delete(choiceId);
59
+ this.fallbackContainers.delete(choiceId);
58
60
  }
59
61
 
60
62
  isEliminated(choiceId: string): boolean {
@@ -65,6 +67,7 @@ export class MaskStrategy implements EliminationStrategy {
65
67
  for (const choiceId of this.highlights.keys()) {
66
68
  this.remove(choiceId);
67
69
  }
70
+ this.fallbackContainers.clear();
68
71
  }
69
72
 
70
73
  getEliminatedIds(): string[] {
@@ -160,13 +163,12 @@ export class MaskStrategy implements EliminationStrategy {
160
163
  container.classList.add("answer-masked-fallback");
161
164
  container.setAttribute("data-eliminated", "true");
162
165
  container.setAttribute("data-eliminated-id", choiceId);
166
+ this.fallbackContainers.set(choiceId, container);
163
167
  this.addAriaAttributes(range);
164
168
  }
165
169
 
166
170
  private removeFallback(choiceId: string): void {
167
- const container = document.querySelector(
168
- `[data-eliminated-id="${choiceId}"]`,
169
- );
171
+ const container = this.fallbackContainers.get(choiceId);
170
172
  if (!container) return;
171
173
 
172
174
  container.classList.remove("answer-masked-fallback");
@@ -176,5 +178,6 @@ export class MaskStrategy implements EliminationStrategy {
176
178
  const range = document.createRange();
177
179
  range.selectNodeContents(container);
178
180
  this.removeAriaAttributes(range);
181
+ this.fallbackContainers.delete(choiceId);
179
182
  }
180
183
  }
@@ -1,4 +1,4 @@
1
- import type { EliminationStrategy } from "./elimination-strategy";
1
+ import type { EliminationStrategy } from "./elimination-strategy.js";
2
2
 
3
3
  /**
4
4
  * Strikethrough strategy using CSS Custom Highlight API
@@ -12,6 +12,7 @@ export class StrikethroughStrategy implements EliminationStrategy {
12
12
 
13
13
  private highlights = new Map<string, Highlight>();
14
14
  private ranges = new Map<string, Range>();
15
+ private fallbackContainers = new Map<string, HTMLElement>();
15
16
 
16
17
  initialize(): void {
17
18
  // Check browser support
@@ -72,6 +73,7 @@ export class StrikethroughStrategy implements EliminationStrategy {
72
73
 
73
74
  this.highlights.delete(choiceId);
74
75
  this.ranges.delete(choiceId);
76
+ this.fallbackContainers.delete(choiceId);
75
77
  }
76
78
 
77
79
  isEliminated(choiceId: string): boolean {
@@ -83,6 +85,7 @@ export class StrikethroughStrategy implements EliminationStrategy {
83
85
  for (const choiceId of this.highlights.keys()) {
84
86
  this.remove(choiceId);
85
87
  }
88
+ this.fallbackContainers.clear();
86
89
  }
87
90
 
88
91
  getEliminatedIds(): string[] {
@@ -218,13 +221,12 @@ export class StrikethroughStrategy implements EliminationStrategy {
218
221
  container.classList.add("answer-eliminated-fallback");
219
222
  container.setAttribute("data-eliminated", "true");
220
223
  container.setAttribute("data-eliminated-id", choiceId);
224
+ this.fallbackContainers.set(choiceId, container);
221
225
  this.addAriaAttributes(range);
222
226
  }
223
227
 
224
228
  private removeFallback(choiceId: string): void {
225
- const container = document.querySelector(
226
- `[data-eliminated-id="${choiceId}"]`,
227
- );
229
+ const container = this.fallbackContainers.get(choiceId);
228
230
  if (!container) return;
229
231
 
230
232
  container.classList.remove("answer-eliminated-fallback");
@@ -235,5 +237,6 @@ export class StrikethroughStrategy implements EliminationStrategy {
235
237
  const range = document.createRange();
236
238
  range.selectNodeContents(container);
237
239
  this.removeAriaAttributes(range);
240
+ this.fallbackContainers.delete(choiceId);
238
241
  }
239
242
  }
@@ -1,14 +1,13 @@
1
1
  <svelte:options
2
2
  customElement={{
3
3
  tag: 'pie-tool-answer-eliminator',
4
- shadow: 'none',
4
+ shadow: 'open',
5
5
  props: {
6
6
  visible: { type: 'Boolean', attribute: 'visible' },
7
7
  toolId: { type: 'String', attribute: 'tool-id' },
8
8
  strategy: { type: 'String', attribute: 'strategy' },
9
9
  alwaysOn: { type: 'Boolean', attribute: 'always-on' },
10
10
  buttonAlignment: { type: 'String', attribute: 'button-alignment' },
11
- coordinator: { type: 'Object' },
12
11
  scopeElement: { type: 'Object', reflect: false },
13
12
 
14
13
  // Store integration (JS properties only)
@@ -46,10 +45,18 @@
46
45
 
47
46
  <script lang="ts">
48
47
 
49
- import type { IToolCoordinator } from '@pie-players/pie-assessment-toolkit';
50
- import { ZIndexLayer } from '@pie-players/pie-assessment-toolkit';
51
- import { onDestroy, onMount } from 'svelte';
52
- import { AnswerEliminatorCore } from './answer-eliminator-core';
48
+ import {
49
+ connectToolRuntimeContext,
50
+ connectToolShellContext,
51
+ ZIndexLayer,
52
+ } from '@pie-players/pie-assessment-toolkit';
53
+ import type {
54
+ AssessmentToolkitShellContext,
55
+ AssessmentToolkitRuntimeContext,
56
+ IToolCoordinator,
57
+ } from '@pie-players/pie-assessment-toolkit';
58
+ import { onMount } from 'svelte';
59
+ import { AnswerEliminatorCore } from './answer-eliminator-core.js';
53
60
 
54
61
  // Props
55
62
  let {
@@ -58,7 +65,6 @@ import { onDestroy, onMount } from 'svelte';
58
65
  strategy = 'strikethrough' as 'strikethrough' | 'mask' | 'gray',
59
66
  alwaysOn = false, // Set true for profile-based accommodation
60
67
  buttonAlignment = 'right' as 'left' | 'right' | 'inline', // Button placement: left, right, or inline with checkbox
61
- coordinator,
62
68
  scopeElement = null, // Container element to limit DOM queries (for multi-item pages)
63
69
 
64
70
  // Store integration
@@ -70,111 +76,82 @@ import { onDestroy, onMount } from 'svelte';
70
76
  strategy?: 'strikethrough' | 'mask' | 'gray';
71
77
  alwaysOn?: boolean;
72
78
  buttonAlignment?: 'left' | 'right' | 'inline';
73
- coordinator?: IToolCoordinator;
74
79
  scopeElement?: HTMLElement | null;
75
80
  elementToolStateStore?: any;
76
81
  globalElementId?: string;
77
82
  } = $props();
78
83
 
79
84
  // State
85
+ let contextHostElement = $state<HTMLElement | null>(null);
86
+ let runtimeContext = $state<AssessmentToolkitRuntimeContext | null>(null);
87
+ let shellContext = $state<AssessmentToolkitShellContext | null>(null);
88
+ const coordinator = $derived(
89
+ runtimeContext?.toolCoordinator as IToolCoordinator | undefined,
90
+ );
80
91
  let core = $state<AnswerEliminatorCore | null>(null);
81
- let eliminatedCount = $state(0);
82
- let mutationObserver = $state<MutationObserver | null>(null);
92
+ let lastShellContextVersion = $state<number | null>(null);
83
93
 
84
94
  // Track registration state
85
- let registered = $state(false);
95
+ let registeredToolId = $state<string | null>(null);
96
+ let registeredCoordinator = $state<IToolCoordinator | null>(null);
86
97
 
87
98
  // Determine if tool should be active (either toggled on OR always-on mode)
88
99
  let isActive = $derived(alwaysOn || visible);
89
100
 
90
- function initializeForCurrentQuestion() {
91
- if (!isActive || !core) return;
101
+ $effect(() => {
102
+ if (!contextHostElement) return;
103
+ return connectToolRuntimeContext(contextHostElement, (value: AssessmentToolkitRuntimeContext) => {
104
+ runtimeContext = value;
105
+ });
106
+ });
92
107
 
93
- // Use scopeElement if provided (for multi-item pages), otherwise search globally
94
- const searchRoot = scopeElement || document.body;
108
+ $effect(() => {
109
+ if (!contextHostElement) return;
110
+ return connectToolShellContext(contextHostElement, (value: AssessmentToolkitShellContext) => {
111
+ shellContext = value;
112
+ });
113
+ });
95
114
 
96
- // Find the current question/item within the search scope
97
- const questionRoot =
98
- searchRoot.querySelector('pie-player') ||
99
- searchRoot.querySelector('multiple-choice') ||
100
- searchRoot.querySelector('ebsr') ||
101
- searchRoot.querySelector('[data-pie-element]') ||
102
- searchRoot;
115
+ function resolveQuestionRoot(): HTMLElement | null {
116
+ return scopeElement || shellContext?.scopeElement || null;
117
+ }
118
+
119
+ function initializeForCurrentQuestion() {
120
+ if (!isActive || !core) return;
121
+ const questionRoot = resolveQuestionRoot();
103
122
 
104
123
  if (!questionRoot) {
105
- console.warn('[AnswerEliminator] Could not find question root within scope');
124
+ console.warn('[AnswerEliminator] Missing shell scope context for question root');
106
125
  return;
107
126
  }
108
127
 
109
- core.initializeForQuestion(questionRoot as HTMLElement);
110
- updateEliminatedCount();
111
- }
112
-
113
- function waitForPIEElements(callback: () => void, timeout: number = 5000) {
114
- // Use scopeElement if provided, otherwise search globally
115
- const searchRoot = scopeElement || document.body;
116
-
117
- // Check if PIE elements already exist
118
- const checkElements = () => {
119
- const questionRoot =
120
- searchRoot.querySelector('pie-player') ||
121
- searchRoot.querySelector('multiple-choice') ||
122
- searchRoot.querySelector('ebsr') ||
123
- searchRoot.querySelector('[data-pie-element]');
124
-
125
- if (questionRoot) {
126
- // Elements found, clean up observer and execute callback
127
- if (mutationObserver) {
128
- mutationObserver.disconnect();
129
- mutationObserver = null;
130
- }
131
- callback();
132
- return true;
133
- }
134
- return false;
135
- };
136
-
137
- // Try immediately first
138
- if (checkElements()) return;
139
-
140
- // Set up MutationObserver to watch for elements within scope
141
- mutationObserver = new MutationObserver(() => {
142
- checkElements();
143
- });
144
-
145
- // Observe the search root for added nodes
146
- mutationObserver.observe(searchRoot, {
147
- childList: true,
148
- subtree: true
149
- });
150
-
151
- // Fallback timeout to prevent infinite observation
152
- setTimeout(() => {
153
- if (mutationObserver) {
154
- mutationObserver.disconnect();
155
- mutationObserver = null;
156
- // Try one last time
157
- checkElements();
158
- }
159
- }, timeout);
128
+ core.initializeForQuestion(questionRoot);
160
129
  }
161
130
 
162
131
  function handleItemChange() {
163
- // Question changed, wait for PIE elements to be rendered using MutationObserver
164
- waitForPIEElements(() => {
165
- initializeForCurrentQuestion();
132
+ requestAnimationFrame(() => {
133
+ requestAnimationFrame(() => {
134
+ initializeForCurrentQuestion();
135
+ });
166
136
  });
167
137
  }
168
138
 
169
- function updateEliminatedCount() {
170
- eliminatedCount = core?.getEliminatedCount() || 0;
171
- }
172
-
173
139
  // Register with coordinator when it becomes available
174
140
  $effect(() => {
175
- if (coordinator && toolId && !registered) {
141
+ if (!coordinator || !toolId) return;
142
+ if (
143
+ registeredCoordinator &&
144
+ registeredToolId &&
145
+ (registeredCoordinator !== coordinator || registeredToolId !== toolId)
146
+ ) {
147
+ registeredCoordinator.unregisterTool(registeredToolId);
148
+ registeredCoordinator = null;
149
+ registeredToolId = null;
150
+ }
151
+ if (!registeredToolId) {
176
152
  coordinator.registerTool(toolId, 'Answer Eliminator', undefined, ZIndexLayer.MODAL);
177
- registered = true;
153
+ registeredCoordinator = coordinator;
154
+ registeredToolId = toolId;
178
155
  }
179
156
  });
180
157
 
@@ -194,42 +171,37 @@ import { onDestroy, onMount } from 'svelte';
194
171
  core.setStoreIntegration(elementToolStateStore, globalElementId);
195
172
  }
196
173
 
197
- // Listen for question changes (PIE player emits this)
198
- document.addEventListener('pie-item-changed', handleItemChange);
199
-
200
- // Listen for custom events from answer-eliminator-core when state changes
201
- document.addEventListener('answer-eliminator-state-change', () => {
202
- updateEliminatedCount();
203
- });
204
-
205
174
  // Initialize for current question if active, otherwise ensure clean state
206
175
  if (isActive) {
207
- // Wait for PIE elements to be mounted using MutationObserver
208
- waitForPIEElements(() => {
209
- initializeForCurrentQuestion();
210
- });
176
+ initializeForCurrentQuestion();
211
177
  } else {
212
178
  // If not active on mount, clear any leftover visual eliminations
213
179
  core.cleanup();
214
180
  }
215
181
 
216
182
  return () => {
217
- // Clean up MutationObserver if still active
218
- if (mutationObserver) {
219
- mutationObserver.disconnect();
220
- mutationObserver = null;
221
- }
222
-
223
183
  core?.destroy();
224
184
  core = null;
225
- if (coordinator && toolId) {
226
- coordinator.unregisterTool(toolId);
185
+ if (registeredCoordinator && registeredToolId) {
186
+ registeredCoordinator.unregisterTool(registeredToolId);
187
+ registeredCoordinator = null;
188
+ registeredToolId = null;
227
189
  }
228
- document.removeEventListener('pie-item-changed', handleItemChange);
229
- document.removeEventListener('answer-eliminator-state-change', updateEliminatedCount);
230
190
  };
231
191
  });
232
192
 
193
+ $effect(() => {
194
+ const shellVersion = shellContext?.contextVersion ?? null;
195
+ if (shellVersion === null) return;
196
+ if (lastShellContextVersion === null) {
197
+ lastShellContextVersion = shellVersion;
198
+ return;
199
+ }
200
+ if (shellVersion === lastShellContextVersion) return;
201
+ lastShellContextVersion = shellVersion;
202
+ handleItemChange();
203
+ });
204
+
233
205
  // Watch for visibility changes to show/hide elimination buttons
234
206
  $effect(() => {
235
207
  if (core) {
@@ -248,3 +220,4 @@ import { onDestroy, onMount } from 'svelte';
248
220
 
249
221
  <!-- No visible UI - tool operates entirely through injected buttons next to choices -->
250
222
  <!-- The toolbar button visibility is managed by tool-toolbar.svelte -->
223
+ <div bind:this={contextHostElement} style="display: none;" aria-hidden="true"></div>