@pie-players/pie-tool-magnifier 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.
@@ -0,0 +1,340 @@
1
+ <!-- Magnifier.svelte - Modern Svelte 5 implementation of HTML magnifier -->
2
+ <script lang="ts">
3
+ import { onMount, untrack } from 'svelte';
4
+
5
+ interface MagnifierProps {
6
+ zoom?: number;
7
+ width?: number;
8
+ height?: number;
9
+ shape?: 'square' | 'circle';
10
+ visible?: boolean;
11
+ onPrepareContent?: (clonedContent: HTMLElement) => void;
12
+ onContentUpdated?: (bodyElement: HTMLElement) => void;
13
+ }
14
+
15
+ let {
16
+ zoom = 2,
17
+ width = 420,
18
+ height = 280,
19
+ shape = 'square',
20
+ visible = $bindable(false),
21
+ onPrepareContent,
22
+ onContentUpdated
23
+ }: MagnifierProps = $props();
24
+
25
+ // Reactive state
26
+ let magnifierEl = $state<HTMLDivElement | null>(null);
27
+ let contentEl = $state<HTMLDivElement | null>(null);
28
+ let clonedBody = $state<HTMLElement | null>(null);
29
+ let position = $state({ x: 200, y: 200 });
30
+ let isDragging = $state(false);
31
+ let dragOffset = $state({ x: 0, y: 0 });
32
+
33
+ // MutationObserver to sync content changes
34
+ let observer: MutationObserver | null = null;
35
+ let syncTimeoutHandle: number | null = null;
36
+
37
+ // Derived styles
38
+ let magnifierStyle = $derived(`
39
+ left: ${position.x}px;
40
+ top: ${position.y}px;
41
+ width: ${width}px;
42
+ height: ${height}px;
43
+ ${shape === 'circle' ? 'border-radius: 50%;' : ''}
44
+ display: ${visible ? 'block' : 'none'};
45
+ `);
46
+
47
+ let contentStyle = $derived(`
48
+ transform: scale(${zoom});
49
+ left: ${-position.x * zoom - window.scrollX * zoom}px;
50
+ top: ${-position.y * zoom - window.scrollY * zoom}px;
51
+ width: ${document.body.scrollWidth}px;
52
+ height: ${document.body.scrollHeight}px;
53
+ `);
54
+
55
+ function cloneDocumentBody() {
56
+ const bodyOriginal = document.body;
57
+ const bodyCopy = bodyOriginal.cloneNode(true) as HTMLElement;
58
+
59
+ // Copy background color
60
+ const bgColor = getComputedStyle(bodyOriginal).backgroundColor;
61
+ if (bgColor && magnifierEl) {
62
+ magnifierEl.style.backgroundColor = bgColor;
63
+ }
64
+
65
+ // Reset styles
66
+ bodyCopy.style.cursor = 'auto';
67
+ bodyCopy.style.paddingTop = '0px';
68
+ bodyCopy.setAttribute('unselectable', 'on');
69
+
70
+ // Copy canvas contents
71
+ const canvasOriginal = bodyOriginal.querySelectorAll('canvas');
72
+ const canvasCopy = bodyCopy.querySelectorAll('canvas');
73
+ if (canvasOriginal.length > 0 && canvasOriginal.length === canvasCopy.length) {
74
+ for (let i = 0; i < canvasOriginal.length; i++) {
75
+ const ctx = canvasCopy[i].getContext('2d');
76
+ if (ctx) {
77
+ try {
78
+ ctx.drawImage(canvasOriginal[i], 0, 0);
79
+ } catch (error) {
80
+ // Silent fail - likely CORS issue
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ // Remove elements that shouldn't be magnified
87
+ const elementsToRemove = [
88
+ ...bodyCopy.querySelectorAll('script'),
89
+ ...bodyCopy.querySelectorAll('audio'),
90
+ ...bodyCopy.querySelectorAll('video'),
91
+ ...bodyCopy.querySelectorAll('.magnifier'),
92
+ ...bodyCopy.querySelectorAll('.magnifier-clone-ignore'),
93
+ ...bodyCopy.querySelectorAll('[data-magnifier-ignore]')
94
+ ];
95
+ elementsToRemove.forEach(el => el.remove());
96
+
97
+ // Custom content preparation
98
+ if (onPrepareContent) {
99
+ onPrepareContent(bodyCopy);
100
+ }
101
+
102
+ return bodyCopy;
103
+ }
104
+
105
+ function updateContent() {
106
+ if (!visible || !contentEl) return;
107
+
108
+ // Clear existing content
109
+ contentEl.innerHTML = '';
110
+
111
+ // Clone and prepare content
112
+ clonedBody = cloneDocumentBody();
113
+ contentEl.appendChild(clonedBody);
114
+
115
+ // Sync scroll positions
116
+ syncScrollPositions();
117
+
118
+ // Notify content update
119
+ if (onContentUpdated && clonedBody) {
120
+ const bodyInClone = clonedBody.querySelector('body');
121
+ if (bodyInClone) {
122
+ onContentUpdated(bodyInClone as HTMLElement);
123
+ }
124
+ }
125
+ }
126
+
127
+ function syncScrollPositions() {
128
+ if (!clonedBody) return;
129
+
130
+ // Find all scrolled divs in the original document
131
+ const scrolledDivs = Array.from(document.querySelectorAll('div')).filter(
132
+ div => div.scrollTop > 0 && !magnifierEl?.contains(div)
133
+ );
134
+
135
+ // Sync scroll positions to cloned elements
136
+ scrolledDivs.forEach(div => {
137
+ const selectors: string[] = [];
138
+
139
+ if (div.id) {
140
+ selectors.push('#' + div.id);
141
+ }
142
+ if (div.className) {
143
+ selectors.push('.' + div.className.split(' ').join('.'));
144
+ }
145
+
146
+ selectors.forEach(selector => {
147
+ const targets = clonedBody!.querySelectorAll(selector);
148
+ if (targets.length === 1) {
149
+ const target = targets[0] as HTMLElement;
150
+ target.scrollTop = div.scrollTop;
151
+ target.scrollLeft = div.scrollLeft;
152
+ }
153
+ });
154
+ });
155
+ }
156
+
157
+ function queueContentUpdate() {
158
+ if (syncTimeoutHandle !== null) {
159
+ clearTimeout(syncTimeoutHandle);
160
+ }
161
+ syncTimeoutHandle = window.setTimeout(() => {
162
+ updateContent();
163
+ }, 100);
164
+ }
165
+
166
+ function startObserving() {
167
+ if (observer) return;
168
+
169
+ observer = new MutationObserver((mutations) => {
170
+ for (const mutation of mutations) {
171
+ // Skip mutations within the magnifier itself
172
+ if (magnifierEl && magnifierEl.contains(mutation.target as Node)) {
173
+ continue;
174
+ }
175
+ queueContentUpdate();
176
+ break;
177
+ }
178
+ });
179
+
180
+ observer.observe(document.body, {
181
+ childList: true,
182
+ subtree: true,
183
+ attributes: true,
184
+ attributeFilter: ['class', 'width', 'height', 'style'],
185
+ attributeOldValue: true
186
+ });
187
+ }
188
+
189
+ function stopObserving() {
190
+ if (observer) {
191
+ observer.disconnect();
192
+ observer = null;
193
+ }
194
+ if (syncTimeoutHandle !== null) {
195
+ clearTimeout(syncTimeoutHandle);
196
+ syncTimeoutHandle = null;
197
+ }
198
+ }
199
+
200
+ // Drag handlers
201
+ function handlePointerDown(e: PointerEvent) {
202
+ // Don't start drag on interactive elements or their children
203
+ const target = e.target as HTMLElement;
204
+
205
+ // Check if the target or any parent is an interactive element
206
+ const interactiveTags = ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'];
207
+ let element: HTMLElement | null = target;
208
+ while (element && element !== magnifierEl) {
209
+ if (interactiveTags.includes(element.tagName.toUpperCase())) {
210
+ return;
211
+ }
212
+ element = element.parentElement;
213
+ }
214
+
215
+ isDragging = true;
216
+ dragOffset = {
217
+ x: e.clientX - position.x,
218
+ y: e.clientY - position.y
219
+ };
220
+
221
+ if (magnifierEl) {
222
+ magnifierEl.setPointerCapture(e.pointerId);
223
+ }
224
+ }
225
+
226
+ function handlePointerMove(e: PointerEvent) {
227
+ if (!isDragging) return;
228
+
229
+ position = {
230
+ x: e.clientX - dragOffset.x,
231
+ y: e.clientY - dragOffset.y
232
+ };
233
+ }
234
+
235
+ function handlePointerUp(e: PointerEvent) {
236
+ if (isDragging && magnifierEl) {
237
+ magnifierEl.releasePointerCapture(e.pointerId);
238
+ }
239
+ isDragging = false;
240
+ }
241
+
242
+ // Scroll sync handler
243
+ function handleScroll(e: Event) {
244
+ if (!visible) return;
245
+
246
+ const target = e.target as HTMLElement;
247
+ if (magnifierEl?.contains(target)) return;
248
+
249
+ // Queue a content update to sync scroll positions
250
+ queueContentUpdate();
251
+ }
252
+
253
+ // Update content when becoming visible
254
+ $effect(() => {
255
+ if (visible) {
256
+ untrack(() => {
257
+ updateContent();
258
+ startObserving();
259
+ });
260
+ } else {
261
+ untrack(() => {
262
+ stopObserving();
263
+ });
264
+ }
265
+ });
266
+
267
+ // Update content when zoom changes
268
+ $effect(() => {
269
+ // Track zoom dependency
270
+ zoom;
271
+ if (visible) {
272
+ untrack(() => {
273
+ updateContent();
274
+ });
275
+ }
276
+ });
277
+
278
+ // Lifecycle
279
+ onMount(() => {
280
+ // Listen for scroll events
281
+ window.addEventListener('scroll', handleScroll, true);
282
+ window.addEventListener('resize', queueContentUpdate);
283
+
284
+ return () => {
285
+ stopObserving();
286
+ window.removeEventListener('scroll', handleScroll, true);
287
+ window.removeEventListener('resize', queueContentUpdate);
288
+ };
289
+ });
290
+ </script>
291
+
292
+ <div
293
+ bind:this={magnifierEl}
294
+ class="magnifier"
295
+ style={magnifierStyle}
296
+ onpointerdown={handlePointerDown}
297
+ onpointermove={handlePointerMove}
298
+ onpointerup={handlePointerUp}
299
+ role="region"
300
+ aria-label="Content magnifier"
301
+ >
302
+ <div bind:this={contentEl} class="magnifier-content" style={contentStyle}></div>
303
+ <div class="magnifier-glass"></div>
304
+ <slot />
305
+ </div>
306
+
307
+ <style>
308
+ .magnifier {
309
+ position: fixed;
310
+ overflow: hidden;
311
+ background-color: white;
312
+ border: 1px solid #555;
313
+ border-radius: 4px;
314
+ z-index: 10000;
315
+ cursor: move;
316
+ touch-action: none;
317
+ }
318
+
319
+ .magnifier-content {
320
+ position: absolute;
321
+ top: 0;
322
+ left: 0;
323
+ overflow: visible;
324
+ display: block;
325
+ transform-origin: left top;
326
+ user-select: none;
327
+ pointer-events: none;
328
+ }
329
+
330
+ .magnifier-glass {
331
+ position: absolute;
332
+ top: 0;
333
+ left: 0;
334
+ width: 100%;
335
+ height: 100%;
336
+ opacity: 0;
337
+ background-color: transparent;
338
+ pointer-events: all;
339
+ }
340
+ </style>
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # PIE Magnifier Tool
2
+
3
+ A draggable magnification tool that allows students to zoom in on any content on the assessment page. This tool is an accessibility accommodation for students with visual impairments.
4
+
5
+ ## Features
6
+
7
+ - **Multiple Zoom Levels**: 1.5x, 2x, and 3x magnification
8
+ - **Draggable**: Can be moved anywhere on the screen using mouse or keyboard
9
+ - **Content Cloning**: Creates a live clone of page content with accurate scroll positions
10
+ - **Keyboard Accessible**: Full keyboard navigation support
11
+ - **Zero DOM Mutation**: Does not modify the original content
12
+
13
+ ## Architecture
14
+
15
+ This tool follows PIE's custom element (Web Component) architecture:
16
+
17
+ - Built with Svelte 5
18
+ - Registered as a custom element `<pie-tool-magnifier>`
19
+ - Integrates with PIE's ToolCoordinator for z-index management
20
+ - Uses Moveable.js for drag functionality
21
+
22
+ ## Comparison to Production Implementations
23
+
24
+ ### Similarities
25
+
26
+ - **Visual Design**: Same zoom levels (1.5x, 2x, 3x), button layout, and frame dimensions
27
+ - **Core Functionality**: DOM cloning, scroll position preservation, mouse tracking
28
+ - **UX Patterns**: Frozen view on mouse leave, continuous update on drag
29
+
30
+ ### Differences
31
+
32
+ - **Framework**: Svelte 5 (with runes) instead of Vue 3 (Composition API)
33
+ - **Architecture**: Web Component (custom element) instead of Vue SFC
34
+ - **Dependencies**: Uses Moveable.js directly instead of VueUse composables
35
+ - **Coordination**: Integrates with PIE's ToolCoordinator for z-index management
36
+ - **Styling**: Uses PIE CSS variables for consistency
37
+ - **DOM Mutation**: Zero mutation approach (clones only, never modifies original)
38
+
39
+ ### Key Architectural Improvements
40
+
41
+ 1. **Web Component Standard**: Framework-agnostic, can be used in any context
42
+ 2. **Modern Svelte 5**: Uses runes (`$state`, `$derived`, `$effect`) for reactive state
43
+ 3. **Cleaner Separation**: Tool coordination is handled by external coordinator, not internal store
44
+ 4. **Accessibility First**: ARIA labels, keyboard navigation, screen reader announcements
45
+
46
+ ## Usage
47
+
48
+ ### As a Custom Element
49
+
50
+ ```html
51
+ <pie-tool-magnifier
52
+ visible="true"
53
+ tool-id="magnifier"
54
+ magnify-root-selector="body"
55
+ ></pie-tool-magnifier>
56
+ ```
57
+
58
+ ### With Coordinator
59
+
60
+ ```typescript
61
+ import { ToolMagnifier } from '@pie-api-aws/pieoneer-tool-magnifier';
62
+ import { ToolCoordinator } from '$lib/assessment-toolkit';
63
+
64
+ const coordinator = new ToolCoordinator();
65
+
66
+ // The tool will auto-register when visible=true
67
+ ```
68
+
69
+ ## Props
70
+
71
+ - `visible` (Boolean): Show/hide the tool
72
+ - `toolId` (String): Unique identifier for this tool instance (default: 'magnifier')
73
+ - `coordinator` (Object): ToolCoordinator instance for z-index management
74
+ - `magnifyRootSelector` (String): CSS selector for root element to magnify (default: 'body')
75
+
76
+ ## Keyboard Navigation
77
+
78
+ - **Arrow Keys**: Move the magnifier frame (10px steps)
79
+ - **1 / 2 / 3**: Set zoom level to 1.5x / 2x / 3x
80
+ - **+ / =**: Cycle to next zoom level
81
+ - **-**: Cycle to previous zoom level
82
+
83
+ ## Accessibility Features
84
+
85
+ - Full keyboard navigation
86
+ - ARIA labels and roles
87
+ - Screen reader announcements for state changes
88
+ - Focus visible indicators
89
+ - High contrast focus rings
90
+
91
+ ## Implementation Notes
92
+
93
+ ### Content Cloning
94
+
95
+ The tool creates a deep clone of the content specified by `magnifyRootSelector` and:
96
+
97
+ 1. Preserves scroll positions of all scrollable elements
98
+ 2. Removes elements with `.magnifier-clone-ignore` class or `[data-magnifier-ignore]` attribute
99
+ 3. Applies scaling and positioning transforms
100
+ 4. Updates on scroll events (debounced)
101
+
102
+ ### Performance
103
+
104
+ - Uses `requestAnimationFrame` for smooth updates
105
+ - Debounces scroll event handlers (100ms)
106
+ - Cleans up all listeners and RAF loops on unmount
107
+ - Only updates during drag or mouse-inside states
108
+
109
+ ### Z-Index Management
110
+
111
+ The tool uses PIE's ZIndexLayer system:
112
+
113
+ - Tool frame: `ZIndexLayer.MODAL` (2002)
114
+ - Moveable controls: `ZIndexLayer.CONTROL` (2003)
115
+
116
+ ### Preventing Self-Magnification
117
+
118
+ The tool adds `.magnifier-clone-ignore` class and `[data-magnifier-ignore]` attribute to its own frame to prevent recursive magnification.
119
+
120
+ ## Browser Compatibility
121
+
122
+ - Chrome/Edge 90+
123
+ - Firefox 90+
124
+ - Safari 14+
125
+
126
+ Requires:
127
+ - Custom Elements (Web Components)
128
+ - CSS Transform
129
+ - RequestAnimationFrame
130
+ - DOMMatrix
131
+
132
+ ## Related Documentation
133
+
134
+ - [PIE Tools Architecture](../../docs/tools-high-level-architecture.md)
135
+ - [ToolCoordinator](../../tools/tool-coordinator.ts)
@@ -0,0 +1,7 @@
1
+ /**
2
+ * pie-tool-magnifier - 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
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}