@pie-players/pie-tool-line-reader 0.1.1

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.
package/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * pie-tool-line-reader - PIE Assessment Tool
3
+ *
4
+ * This package exports a web component built from Svelte.
5
+ * Import the built version for CDN usage, or the .svelte source for Svelte projects.
6
+ */
7
+
8
+ // Re-export any TypeScript types defined in the package
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@pie-players/pie-tool-line-reader",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Reading guide overlay tool for PIE assessment player",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/pie-framework/pie-players.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "keywords": [
14
+ "pie",
15
+ "assessment",
16
+ "tool",
17
+ "line-reader",
18
+ "reading",
19
+ "accessibility"
20
+ ],
21
+ "svelte": "./tool-line-reader.svelte",
22
+ "main": "./dist/tool-line-reader.js",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/tool-line-reader.js",
27
+ "svelte": "./tool-line-reader.svelte"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "tool-line-reader.svelte",
33
+ "index.ts",
34
+ "package.json"
35
+ ],
36
+ "peerDependencies": {
37
+ "svelte": "^5.0.0"
38
+ },
39
+ "license": "MIT",
40
+ "unpkg": "./dist/tool-line-reader.js",
41
+ "jsdelivr": "./dist/tool-line-reader.js",
42
+ "dependencies": {
43
+ "@pie-players/pie-assessment-toolkit": "workspace:*",
44
+ "@pie-players/pie-players-shared": "workspace:*"
45
+ },
46
+ "types": "./dist/index.d.ts",
47
+ "scripts": {
48
+ "build": "vite build",
49
+ "dev": "vite build --watch",
50
+ "typecheck": "tsc --noEmit",
51
+ "lint": "biome check ."
52
+ },
53
+ "devDependencies": {
54
+ "@biomejs/biome": "^2.3.10",
55
+ "@sveltejs/vite-plugin-svelte": "^6.1.4",
56
+ "svelte": "^5.16.1",
57
+ "typescript": "^5.7.0",
58
+ "vite": "^7.0.8",
59
+ "vite-plugin-dts": "^4.5.3"
60
+ }
61
+ }
@@ -0,0 +1,544 @@
1
+ <svelte:options
2
+ customElement={{
3
+ tag: 'pie-tool-line-reader',
4
+ shadow: 'none',
5
+ props: {
6
+ visible: { type: 'Boolean', attribute: 'visible' },
7
+ toolId: { type: 'String', attribute: 'tool-id' },
8
+ coordinator: { type: 'Object' }
9
+ }
10
+ }}
11
+ />
12
+
13
+ <script lang="ts">
14
+
15
+ import type { IToolCoordinator } from '@pie-players/pie-assessment-toolkit';
16
+ import { ZIndexLayer } from '@pie-players/pie-assessment-toolkit';
17
+ import ToolSettingsButton from '@pie-players/pie-players-shared/components/ToolSettingsButton.svelte';
18
+ import ToolSettingsPanel from '@pie-players/pie-players-shared/components/ToolSettingsPanel.svelte';
19
+ import { onMount } from 'svelte';
20
+
21
+ // Props
22
+ let { visible = false, toolId = 'lineReader', coordinator }: { visible?: boolean; toolId?: string; coordinator?: IToolCoordinator } = $props();
23
+
24
+ // Check if running in browser
25
+ const isBrowser = typeof window !== 'undefined';
26
+
27
+ // State
28
+ let containerEl = $state<HTMLDivElement | undefined>();
29
+ let settingsButtonEl = $state<HTMLButtonElement | undefined>();
30
+ let isDragging = $state(false);
31
+ let isResizing = $state(false);
32
+ let position = $state({
33
+ x: isBrowser ? window.innerWidth / 2 : 400,
34
+ y: isBrowser ? window.innerHeight / 2 : 300
35
+ });
36
+ let size = $state({ width: 600, height: 60 });
37
+ let dragStart = $state({ x: 0, y: 0 });
38
+ let resizeStart = $state({ width: 0, height: 0, mouseY: 0 });
39
+ let announceText = $state('');
40
+ let currentColor = $state('#ffff00'); // Yellow
41
+ let currentOpacity = $state(0.3);
42
+ let maskingMode = $state<'highlight' | 'obscure'>('highlight');
43
+ let settingsOpen = $state(false);
44
+
45
+ // Track registration state
46
+ let registered = $state(false);
47
+
48
+ // Available colors
49
+ const colors = [
50
+ { name: 'Yellow', value: '#ffff00' },
51
+ { name: 'Blue', value: '#00bfff' },
52
+ { name: 'Pink', value: '#ff69b4' },
53
+ { name: 'Green', value: '#00ff7f' },
54
+ { name: 'Orange', value: '#ffa500' }
55
+ ];
56
+
57
+ // Keyboard navigation constants
58
+ const MOVE_STEP = 10; // pixels
59
+ const RESIZE_STEP = 10; // pixels
60
+
61
+ function announce(message: string) {
62
+ announceText = message;
63
+ setTimeout(() => announceText = '', 1000);
64
+ }
65
+
66
+ function cycleColor() {
67
+ const currentIndex = colors.findIndex(c => c.value === currentColor);
68
+ const nextIndex = (currentIndex + 1) % colors.length;
69
+ currentColor = colors[nextIndex].value;
70
+ announce(`Color changed to ${colors[nextIndex].name}`);
71
+ }
72
+
73
+ function adjustOpacity(delta: number) {
74
+ currentOpacity = Math.max(0.1, Math.min(0.9, currentOpacity + delta));
75
+ announce(`Opacity ${Math.round(currentOpacity * 100)}%`);
76
+ }
77
+
78
+ function toggleMaskingMode() {
79
+ maskingMode = maskingMode === 'highlight' ? 'obscure' : 'highlight';
80
+ announce(`Mode changed to ${maskingMode === 'highlight' ? 'highlight' : 'masking'}`);
81
+ }
82
+
83
+ function toggleSettings() {
84
+ settingsOpen = !settingsOpen;
85
+ }
86
+
87
+ function closeSettings() {
88
+ settingsOpen = false;
89
+ }
90
+
91
+ function setColor(color: string) {
92
+ currentColor = color;
93
+ const colorName = colors.find(c => c.value === color)?.name || 'Unknown';
94
+ announce(`Color changed to ${colorName}`);
95
+ }
96
+
97
+ // Pointer event handlers (better for web components)
98
+ function handlePointerDown(e: PointerEvent) {
99
+ const target = e.target as HTMLElement;
100
+
101
+ // Check if clicking the resize handle
102
+ if (target.closest('.resize-handle')) {
103
+ startResizing(e);
104
+ } else if (target.closest('.tool-settings-button') || target.closest('.tool-settings-panel')) {
105
+ // Don't start dragging when clicking settings
106
+ return;
107
+ } else {
108
+ startDragging(e);
109
+ }
110
+ }
111
+
112
+ function startDragging(e: PointerEvent) {
113
+ if (!containerEl) return;
114
+
115
+ // Capture pointer for isolated event handling
116
+ containerEl.setPointerCapture(e.pointerId);
117
+ isDragging = true;
118
+ dragStart = {
119
+ x: e.clientX - position.x,
120
+ y: e.clientY - position.y
121
+ };
122
+
123
+ coordinator?.bringToFront(containerEl);
124
+
125
+ // Add pointer move/up handlers to element (not window!)
126
+ containerEl.addEventListener('pointermove', handlePointerMove);
127
+ containerEl.addEventListener('pointerup', handlePointerUp);
128
+
129
+ e.preventDefault();
130
+ }
131
+
132
+ function startResizing(e: PointerEvent) {
133
+ if (!containerEl) return;
134
+
135
+ // Capture pointer for isolated event handling
136
+ containerEl.setPointerCapture(e.pointerId);
137
+
138
+ isResizing = true;
139
+ resizeStart = {
140
+ width: size.width,
141
+ height: size.height,
142
+ mouseY: e.clientY
143
+ };
144
+
145
+ coordinator?.bringToFront(containerEl);
146
+
147
+ // Add pointer move/up handlers to element (not window!)
148
+ containerEl.addEventListener('pointermove', handlePointerMove);
149
+ containerEl.addEventListener('pointerup', handlePointerUp);
150
+
151
+ e.preventDefault();
152
+ e.stopPropagation();
153
+ }
154
+
155
+ function handlePointerMove(e: PointerEvent) {
156
+ if (isDragging) {
157
+ position = {
158
+ x: e.clientX - dragStart.x,
159
+ y: e.clientY - dragStart.y
160
+ };
161
+ } else if (isResizing) {
162
+ // Vertical resize only
163
+ const deltaY = e.clientY - resizeStart.mouseY;
164
+ size.height = Math.max(20, Math.min(400, resizeStart.height + deltaY));
165
+ }
166
+ }
167
+
168
+ function handlePointerUp(e: PointerEvent) {
169
+ if (!containerEl) return;
170
+
171
+ // Release pointer capture
172
+ containerEl.releasePointerCapture(e.pointerId);
173
+
174
+ // Clean up event listeners
175
+ containerEl.removeEventListener('pointermove', handlePointerMove);
176
+ containerEl.removeEventListener('pointerup', handlePointerUp);
177
+
178
+ isDragging = false;
179
+ isResizing = false;
180
+ }
181
+
182
+ function handleKeyDown(e: KeyboardEvent) {
183
+ let handled = false;
184
+
185
+ switch (e.key) {
186
+ case 'ArrowUp':
187
+ position.y -= MOVE_STEP;
188
+ announce(`Moved up to ${Math.round(position.y)}`);
189
+ handled = true;
190
+ break;
191
+ case 'ArrowDown':
192
+ position.y += MOVE_STEP;
193
+ announce(`Moved down to ${Math.round(position.y)}`);
194
+ handled = true;
195
+ break;
196
+ case 'ArrowLeft':
197
+ position.x -= MOVE_STEP;
198
+ announce(`Moved left to ${Math.round(position.x)}`);
199
+ handled = true;
200
+ break;
201
+ case 'ArrowRight':
202
+ position.x += MOVE_STEP;
203
+ announce(`Moved right to ${Math.round(position.x)}`);
204
+ handled = true;
205
+ break;
206
+ case '+':
207
+ case '=':
208
+ size.height = Math.min(400, size.height + RESIZE_STEP);
209
+ announce(`Height ${size.height} pixels`);
210
+ handled = true;
211
+ break;
212
+ case '-':
213
+ case '_':
214
+ size.height = Math.max(20, size.height - RESIZE_STEP);
215
+ announce(`Height ${size.height} pixels`);
216
+ handled = true;
217
+ break;
218
+ case 'c':
219
+ case 'C':
220
+ cycleColor();
221
+ handled = true;
222
+ break;
223
+ case ']':
224
+ adjustOpacity(0.1);
225
+ handled = true;
226
+ break;
227
+ case '[':
228
+ adjustOpacity(-0.1);
229
+ handled = true;
230
+ break;
231
+ case 'm':
232
+ case 'M':
233
+ toggleMaskingMode();
234
+ handled = true;
235
+ break;
236
+ }
237
+
238
+ if (handled) {
239
+ e.preventDefault();
240
+ }
241
+ }
242
+
243
+ // Register with coordinator when it becomes available
244
+ $effect(() => {
245
+ if (coordinator && toolId && !registered) {
246
+ coordinator.registerTool(toolId, 'Line Reader', undefined, ZIndexLayer.TOOL);
247
+ registered = true;
248
+ }
249
+ });
250
+
251
+ onMount(() => {
252
+ return () => {
253
+ if (coordinator && toolId) {
254
+ coordinator.unregisterTool(toolId);
255
+ }
256
+ };
257
+ });
258
+
259
+ // Update element reference when container becomes available
260
+ $effect(() => {
261
+ if (coordinator && containerEl && toolId) {
262
+ coordinator.updateToolElement(toolId, containerEl);
263
+ }
264
+ });
265
+
266
+ // Auto-focus when tool becomes visible
267
+ $effect(() => {
268
+ if (visible && containerEl) {
269
+ setTimeout(() => containerEl?.focus(), 100);
270
+ }
271
+ });
272
+
273
+ // Computed background color with opacity
274
+ let backgroundColor = $derived(currentColor + Math.round(currentOpacity * 255).toString(16).padStart(2, '0'));
275
+ </script>
276
+
277
+ {#if visible}
278
+ <!-- Screen reader announcements -->
279
+ <div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
280
+ {announceText}
281
+ </div>
282
+
283
+ <!-- Masking overlays (only in obscure mode) - 4 rectangles around the line reader window -->
284
+ {#if maskingMode === 'obscure'}
285
+ <!-- Top mask - from top of viewport to top of line reader -->
286
+ <div
287
+ class="line-reader-mask line-reader-mask-top"
288
+ style="height: {Math.max(0, position.y - size.height / 2)}px;"
289
+ aria-hidden="true"
290
+ ></div>
291
+ <!-- Bottom mask - from bottom of line reader to bottom of viewport -->
292
+ <div
293
+ class="line-reader-mask line-reader-mask-bottom"
294
+ style="top: {position.y + size.height / 2}px;"
295
+ aria-hidden="true"
296
+ ></div>
297
+ <!-- Left mask - left side of line reader window -->
298
+ <div
299
+ class="line-reader-mask line-reader-mask-left"
300
+ style="top: {position.y - size.height / 2}px; height: {size.height}px; width: {Math.max(0, position.x - size.width / 2)}px;"
301
+ aria-hidden="true"
302
+ ></div>
303
+ <!-- Right mask - right side of line reader window -->
304
+ <div
305
+ class="line-reader-mask line-reader-mask-right"
306
+ style="top: {position.y - size.height / 2}px; height: {size.height}px; left: {position.x + size.width / 2}px;"
307
+ aria-hidden="true"
308
+ ></div>
309
+ {/if}
310
+
311
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
312
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
313
+ <div
314
+ bind:this={containerEl}
315
+ class="line-reader-frame"
316
+ class:masking-mode={maskingMode === 'obscure'}
317
+ style="left: {position.x}px; top: {position.y}px; width: {size.width}px; height: {size.height}px;"
318
+ onpointerdown={handlePointerDown}
319
+ onkeydown={handleKeyDown}
320
+ role="application"
321
+ tabindex="0"
322
+ aria-label="Line Reader tool. Mode: {maskingMode === 'highlight' ? 'Highlight' : 'Masking'}. Use arrow keys to move, +/- to resize height, C to change color, [ and ] to adjust opacity, M to toggle mode. Current color: {colors.find(c => c.value === currentColor)?.name}, Opacity: {Math.round(currentOpacity * 100)}%"
323
+ aria-roledescription="Draggable and resizable reading guide overlay"
324
+ >
325
+ <div class="line-reader-container" style="background-color: {backgroundColor};">
326
+ <!-- Settings Button -->
327
+ <ToolSettingsButton
328
+ bind:buttonEl={settingsButtonEl}
329
+ onClick={toggleSettings}
330
+ ariaLabel="Line reader settings"
331
+ active={settingsOpen}
332
+ />
333
+ </div>
334
+
335
+ <!-- Resize handle -->
336
+ <div
337
+ class="resize-handle resize-handle-bottom"
338
+ title="Drag to resize height"
339
+ role="button"
340
+ tabindex="-1"
341
+ aria-label="Resize handle - drag to adjust height"
342
+ >
343
+ <svg width="20" height="8" viewBox="0 0 20 8" aria-hidden="true">
344
+ <rect x="8" y="3" width="4" height="2" fill="#4CAF50" rx="1"/>
345
+ </svg>
346
+ </div>
347
+ </div>
348
+
349
+ <!-- Settings Panel - Rendered outside line-reader-frame to avoid height constraints -->
350
+ <ToolSettingsPanel
351
+ open={settingsOpen}
352
+ title="Line Reader Settings"
353
+ onClose={closeSettings}
354
+ anchorEl={settingsButtonEl}
355
+ >
356
+ <!-- Mode Selection - First, as it determines what other settings are relevant -->
357
+ <fieldset class="setting-group">
358
+ <legend>Mode</legend>
359
+ <label>
360
+ <input
361
+ type="radio"
362
+ name="mode"
363
+ value="highlight"
364
+ checked={maskingMode === 'highlight'}
365
+ onchange={() => { maskingMode = 'highlight'; announce('Mode changed to highlight'); }}
366
+ />
367
+ <span>Highlight</span>
368
+ </label>
369
+ <label>
370
+ <input
371
+ type="radio"
372
+ name="mode"
373
+ value="obscure"
374
+ checked={maskingMode === 'obscure'}
375
+ onchange={() => { maskingMode = 'obscure'; announce('Mode changed to masking'); }}
376
+ />
377
+ <span>Masking</span>
378
+ </label>
379
+ </fieldset>
380
+
381
+ <!-- Color Selection - Only shown in Highlight mode -->
382
+ {#if maskingMode === 'highlight'}
383
+ <fieldset class="setting-group">
384
+ <legend>Color</legend>
385
+ {#each colors as color}
386
+ <label>
387
+ <input
388
+ type="radio"
389
+ name="color"
390
+ value={color.value}
391
+ checked={currentColor === color.value}
392
+ onchange={() => setColor(color.value)}
393
+ />
394
+ <div class="color-swatch" style="background-color: {color.value};"></div>
395
+ <span>{color.name}</span>
396
+ </label>
397
+ {/each}
398
+ </fieldset>
399
+
400
+ <!-- Opacity Slider - Only shown in Highlight mode -->
401
+ <div class="setting-group">
402
+ <div class="setting-label">
403
+ <span>Opacity</span>
404
+ <span class="setting-value" aria-live="polite">{Math.round(currentOpacity * 100)}%</span>
405
+ </div>
406
+ <input
407
+ type="range"
408
+ min="10"
409
+ max="90"
410
+ step="5"
411
+ value={currentOpacity * 100}
412
+ oninput={(e) => {
413
+ currentOpacity = Number(e.currentTarget.value) / 100;
414
+ announce(`Opacity ${Math.round(currentOpacity * 100)}%`);
415
+ }}
416
+ aria-label="Opacity"
417
+ aria-valuemin="10"
418
+ aria-valuemax="90"
419
+ aria-valuenow={Math.round(currentOpacity * 100)}
420
+ aria-valuetext="{Math.round(currentOpacity * 100)} percent"
421
+ />
422
+ </div>
423
+ {/if}
424
+ </ToolSettingsPanel>
425
+ {/if}
426
+
427
+ <style>
428
+ .sr-only {
429
+ position: absolute;
430
+ width: 1px;
431
+ height: 1px;
432
+ padding: 0;
433
+ margin: -1px;
434
+ overflow: hidden;
435
+ clip: rect(0, 0, 0, 0);
436
+ white-space: nowrap;
437
+ border-width: 0;
438
+ }
439
+
440
+ .line-reader-frame {
441
+ border: 2px solid rgba(76, 175, 80, 0.8);
442
+ cursor: move;
443
+ overflow: visible;
444
+ position: absolute;
445
+ transform: translate(-50%, -50%);
446
+ user-select: none;
447
+ pointer-events: auto;
448
+ touch-action: none;
449
+ }
450
+
451
+ .line-reader-frame:focus {
452
+ outline: 3px solid #4A90E2;
453
+ outline-offset: 2px;
454
+ }
455
+
456
+ .line-reader-frame:focus-visible {
457
+ outline: 3px solid #4A90E2;
458
+ outline-offset: 2px;
459
+ }
460
+
461
+ .line-reader-container {
462
+ width: 100%;
463
+ height: 100%;
464
+ position: relative;
465
+ transition: background-color 0.2s ease;
466
+ }
467
+
468
+
469
+ .resize-handle {
470
+ position: absolute;
471
+ cursor: ns-resize;
472
+ z-index: 10;
473
+ display: flex;
474
+ align-items: center;
475
+ justify-content: center;
476
+ }
477
+
478
+ .resize-handle-bottom {
479
+ bottom: -10px;
480
+ left: 50%;
481
+ transform: translateX(-50%);
482
+ width: 40px;
483
+ height: 16px;
484
+ background-color: rgba(255, 255, 255, 0.9);
485
+ border-radius: 8px;
486
+ border: 2px solid #4CAF50;
487
+ }
488
+
489
+ .resize-handle:hover {
490
+ background-color: rgba(76, 175, 80, 0.2);
491
+ }
492
+
493
+ .resize-handle:active {
494
+ cursor: ns-resize;
495
+ }
496
+
497
+ .line-reader-frame:active {
498
+ cursor: grabbing;
499
+ }
500
+
501
+ /* Masking overlays for obscure mode - 4 rectangles covering all areas except line reader window */
502
+ .line-reader-mask {
503
+ position: fixed;
504
+ background: rgba(0, 0, 0, 0.85);
505
+ z-index: 999;
506
+ pointer-events: none;
507
+ }
508
+
509
+ .line-reader-mask-top {
510
+ top: 0;
511
+ left: 0;
512
+ right: 0;
513
+ /* Height set via inline style */
514
+ }
515
+
516
+ .line-reader-mask-bottom {
517
+ /* Top set via inline style */
518
+ left: 0;
519
+ right: 0;
520
+ bottom: 0;
521
+ }
522
+
523
+ .line-reader-mask-left {
524
+ /* Top, height, and width set via inline style */
525
+ left: 0;
526
+ }
527
+
528
+ .line-reader-mask-right {
529
+ /* Top, height, and left set via inline style */
530
+ right: 0;
531
+ }
532
+
533
+ /* In masking mode, change the window appearance */
534
+ .line-reader-frame.masking-mode {
535
+ border-color: rgba(76, 175, 80, 1);
536
+ box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.8), 0 0 20px rgba(76, 175, 80, 0.4);
537
+ }
538
+
539
+ /* In masking mode, the window should be transparent to show content underneath */
540
+ .line-reader-frame.masking-mode .line-reader-container {
541
+ background-color: transparent !important;
542
+ }
543
+
544
+ </style>