@gemx-dev/clarity-visualize 0.8.52 → 0.8.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemx-dev/clarity-visualize",
3
- "version": "0.8.52",
3
+ "version": "0.8.54",
4
4
  "description": "Clarity visualize",
5
5
  "author": "Microsoft Corp.",
6
6
  "license": "MIT",
@@ -9,7 +9,7 @@
9
9
  "unpkg": "build/clarity.visualize.min.js",
10
10
  "types": "types/index.d.ts",
11
11
  "dependencies": {
12
- "@gemx-dev/clarity-decode": "^0.8.52"
12
+ "@gemx-dev/clarity-decode": "^0.8.54"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@rollup/plugin-commonjs": "^24.0.0",
@@ -12,6 +12,33 @@ All custom modules follow the functional module pattern:
12
12
 
13
13
  ## Modules
14
14
 
15
+ ### Canvas Layer Module (`canvas-layer.ts`)
16
+
17
+ Manages canvas z-index and stacking context with top-layer elements (modal dialogs).
18
+
19
+ **Functions:**
20
+
21
+ - `getOpenDialogs(doc)` - Gets all open modal dialogs
22
+ - `hideDialogsTemporarily(doc)` - Hides modal dialogs and returns restore function
23
+ - `setupCanvasLayer(canvas, options)` - Sets up canvas with proper layer handling
24
+ - `applyCanvasLayerWithDialogAwareness(canvas, onShow, onHide)` - Dialog-aware canvas management
25
+
26
+ **Usage:**
27
+
28
+ ```typescript
29
+ import * as canvasLayer from "./custom/canvas-layer";
30
+
31
+ // Hide dialogs when showing heatmap
32
+ const restoreDialogs = canvasLayer.hideDialogsTemporarily(doc);
33
+
34
+ // Later, restore dialogs
35
+ restoreDialogs();
36
+ ```
37
+
38
+ **Why this is needed:**
39
+
40
+ Modal dialogs use the browser's **top-layer**, which has a higher stacking context than any z-index value (even `z-index: 2147483647`). This module temporarily hides modal dialogs when the heatmap canvas is active, ensuring the canvas is visible.
41
+
15
42
  ### Dialog Module (`dialog.ts`)
16
43
 
17
44
  Renders HTML `<dialog>` elements with proper top-layer and backdrop support.
@@ -0,0 +1,351 @@
1
+ import { Constant, Setting } from "@clarity-types/visualize";
2
+
3
+ /**
4
+ * Custom module for managing canvas layer z-index with dialog top-layer
5
+ * Handles stacking context when modal dialogs are present
6
+ *
7
+ * Strategy: Render canvas in parent window (outside iframe) to avoid
8
+ * z-index conflicts with top-layer elements inside the iframe
9
+ */
10
+
11
+ export interface CanvasLayerOptions {
12
+ strategy: 'parent-window' | 'max-z-index' | 'hide-dialogs' | 'popover';
13
+ }
14
+
15
+ export interface ParentCanvasInfo {
16
+ canvas: HTMLCanvasElement;
17
+ iframe: HTMLIFrameElement;
18
+ cleanup: () => void;
19
+ }
20
+
21
+ /**
22
+ * Creates a canvas as sibling to the iframe (same parent container)
23
+ * This allows the canvas to avoid z-index conflicts with top-layer elements inside iframe
24
+ *
25
+ * @param iframeDoc - Document inside the iframe
26
+ * @param canvasId - ID for the canvas element
27
+ * @returns Canvas info with cleanup function
28
+ */
29
+ export function createParentWindowCanvas(
30
+ iframeDoc: Document,
31
+ ): ParentCanvasInfo | null {
32
+ // Check if we're inside an iframe
33
+ if (iframeDoc.defaultView === iframeDoc.defaultView?.top) {
34
+ throw new Error('Not in iframe: ' + iframeDoc.defaultView);
35
+ }
36
+
37
+ try {
38
+ const parentWindow = iframeDoc.defaultView?.parent;
39
+ const parentDoc = parentWindow?.document;
40
+ if (!parentDoc) throw new Error('Parent document not found');
41
+
42
+ const iframe = parentDoc.getElementById('clarity-iframe');
43
+ if (!iframe) throw new Error('Iframe not found');
44
+
45
+ const targetIframe = iframe as HTMLIFrameElement;
46
+ const iframeParent = targetIframe.parentElement;
47
+ if (!iframeParent) throw new Error('Iframe parent not found');
48
+
49
+ // Create canvas as sibling to iframe
50
+ let canvas = parentDoc.getElementById(Constant.HeatmapCanvas) as HTMLCanvasElement;
51
+ if (canvas === null) {
52
+ canvas = parentDoc.createElement('canvas');
53
+ canvas.id = Constant.HeatmapCanvas;
54
+ canvas.width = 0;
55
+ canvas.height = 0;
56
+ // canvas.style.position = Constant.Absolute;
57
+ canvas.style.zIndex = `${Setting.ZIndex}`;
58
+ canvas.style.pointerEvents = 'none'; // Allow clicks to pass through
59
+ iframeParent.appendChild(canvas);
60
+ }
61
+
62
+ // // Set canvas size to match iframe
63
+ // const updateCanvasSize = () => {
64
+ // canvas.width = targetIframe!.offsetWidth;
65
+ // canvas.height = targetIframe!.offsetHeight;
66
+ // };
67
+
68
+ // updateCanvasSize();
69
+
70
+ // Insert canvas as sibling to iframe (in same parent container)
71
+ // iframeParent.appendChild(canvas);
72
+
73
+ // Update size on resize
74
+ // const handleUpdate = () => updateCanvasSize();
75
+ // parentWindow!.addEventListener('resize', handleUpdate, true);
76
+
77
+ const cleanup = () => {
78
+ // parentWindow!.removeEventListener('resize', handleUpdate, true);
79
+ if (canvas.parentNode) {
80
+ canvas.parentNode.removeChild(canvas);
81
+ }
82
+ };
83
+
84
+ return {
85
+ canvas,
86
+ iframe: targetIframe,
87
+ cleanup
88
+ };
89
+ } catch (e) {
90
+ throw new Error(e);
91
+ }
92
+ }
93
+
94
+ // ======================================================== //
95
+ // ======================================================== //
96
+
97
+
98
+ /**
99
+ * Gets all open modal dialogs in the document
100
+ */
101
+ export function getOpenDialogs(doc: Document): HTMLDialogElement[] {
102
+ const dialogs = Array.from(doc.querySelectorAll('dialog[open]'));
103
+ return dialogs.filter(d => {
104
+ const dialog = d as HTMLDialogElement;
105
+ // Check if it's a modal dialog (in top-layer)
106
+ try {
107
+ const backdropStyle = window.getComputedStyle(dialog, '::backdrop');
108
+ return backdropStyle.getPropertyValue('display') !== 'none';
109
+ } catch {
110
+ return false;
111
+ }
112
+ }) as HTMLDialogElement[];
113
+ }
114
+
115
+ /**
116
+ * Temporarily hides modal dialogs to allow canvas to be visible
117
+ * Returns a restore function to bring dialogs back
118
+ */
119
+ export function hideDialogsTemporarily(doc: Document): () => void {
120
+ const dialogs = getOpenDialogs(doc);
121
+ const dialogStates: Array<{ dialog: HTMLDialogElement; wasOpen: boolean }> = [];
122
+
123
+ dialogs.forEach(dialog => {
124
+ if (dialog.open) {
125
+ dialogStates.push({ dialog, wasOpen: true });
126
+ dialog.close();
127
+ }
128
+ });
129
+
130
+ // Return restore function
131
+ return () => {
132
+ dialogStates.forEach(({ dialog, wasOpen }) => {
133
+ if (wasOpen && !dialog.open) {
134
+ try {
135
+ dialog.showModal();
136
+ } catch (e) {
137
+ console.warn('Failed to restore dialog:', e);
138
+ }
139
+ }
140
+ });
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Sets up canvas with proper z-index handling
146
+ *
147
+ * @param canvas - The canvas element
148
+ * @param options - Layer options
149
+ * @returns Cleanup function
150
+ */
151
+ export function setupCanvasLayer(
152
+ canvas: HTMLCanvasElement,
153
+ options: CanvasLayerOptions = { strategy: 'max-z-index' }
154
+ ): () => void {
155
+ switch (options.strategy) {
156
+ case 'hide-dialogs':
157
+ // Hide dialogs when canvas is active
158
+ return hideDialogsTemporarily(canvas.ownerDocument);
159
+
160
+ case 'popover':
161
+ // Use Popover API if available (also uses top-layer)
162
+ if ('popover' in canvas) {
163
+ try {
164
+ (canvas as any).popover = 'manual';
165
+ (canvas as any).showPopover();
166
+ return () => {
167
+ try {
168
+ (canvas as any).hidePopover();
169
+ } catch (e) {
170
+ // Ignore
171
+ }
172
+ };
173
+ } catch (e) {
174
+ console.warn('Popover API not available, falling back to max z-index');
175
+ }
176
+ }
177
+ // Fallback to max-z-index
178
+ return () => {};
179
+
180
+ case 'max-z-index':
181
+ default:
182
+ // Use maximum z-index (works for non-modal content)
183
+ // Modal dialogs will still be on top due to top-layer
184
+ return () => {};
185
+ }
186
+ }
187
+
188
+
189
+
190
+ /**
191
+ * Synchronizes canvas content from iframe to parent window canvas
192
+ *
193
+ * @param iframeCanvas - Canvas inside iframe
194
+ * @param parentCanvas - Canvas in parent window
195
+ */
196
+ export function syncCanvasContent(
197
+ iframeCanvas: HTMLCanvasElement,
198
+ parentCanvas: HTMLCanvasElement
199
+ ): void {
200
+ const ctx = parentCanvas.getContext('2d');
201
+ if (ctx) {
202
+ ctx.clearRect(0, 0, parentCanvas.width, parentCanvas.height);
203
+ ctx.drawImage(iframeCanvas, 0, 0);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Alternative approach: Make dialogs "transparent" for rendering
209
+ * Sets pointer-events: none on dialogs so canvas can render on top
210
+ */
211
+ export function makeDialogsTransparent(doc: Document): () => void {
212
+ const dialogs = Array.from(doc.querySelectorAll('dialog[open]')) as HTMLDialogElement[];
213
+ const originalStyles: Map<HTMLDialogElement, string> = new Map();
214
+
215
+ dialogs.forEach((dialog) => {
216
+ originalStyles.set(dialog, dialog.style.pointerEvents || '');
217
+ dialog.style.pointerEvents = 'none';
218
+ dialog.style.zIndex = '2147483646'; // Just below canvas
219
+ });
220
+
221
+ // Return restore function
222
+ return () => {
223
+ dialogs.forEach((dialog) => {
224
+ const original = originalStyles.get(dialog);
225
+ if (original !== undefined) {
226
+ dialog.style.pointerEvents = original;
227
+ dialog.style.zIndex = '';
228
+ }
229
+ });
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Solution 3: Lower dialog z-index temporarily
235
+ * Moves dialog out of top-layer by closing and re-showing as non-modal
236
+ */
237
+ export function lowerDialogZIndex(doc: Document): () => void {
238
+ const dialogs = getOpenDialogs(doc);
239
+ const dialogStates: Array<{ dialog: HTMLDialogElement; wasModal: boolean }> = [];
240
+
241
+ dialogs.forEach((dialog) => {
242
+ const wasModal = true; // If it's in getOpenDialogs, it's modal
243
+ dialogStates.push({ dialog, wasModal });
244
+
245
+ // Close modal and reopen as non-modal (removes from top-layer)
246
+ dialog.close();
247
+ dialog.show(); // Non-modal, can be controlled by z-index
248
+ dialog.style.zIndex = '2147483646'; // Below canvas
249
+ });
250
+
251
+ // Return restore function
252
+ return () => {
253
+ dialogStates.forEach(({ dialog, wasModal }) => {
254
+ if (wasModal) {
255
+ dialog.close();
256
+ try {
257
+ dialog.showModal(); // Restore to top-layer
258
+ dialog.style.zIndex = '';
259
+ } catch (e) {
260
+ console.warn('Failed to restore modal dialog:', e);
261
+ }
262
+ }
263
+ });
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Solution 4: Use mix-blend-mode to render canvas "through" dialog
269
+ * This is experimental and may not work in all browsers
270
+ */
271
+ export function setupCanvasBlendMode(canvas: HTMLCanvasElement): void {
272
+ canvas.style.mixBlendMode = 'multiply';
273
+ canvas.style.isolation = 'isolate';
274
+ }
275
+
276
+ /**
277
+ * Master function: Apply best strategy based on context
278
+ * Tries multiple approaches in order of preference
279
+ */
280
+ export function applyBestCanvasStrategy(
281
+ doc: Document,
282
+ canvasId: string,
283
+ strategy?: 'parent-window' | 'lower-dialog' | 'transparent' | 'auto'
284
+ ): { canvas: HTMLCanvasElement | null; cleanup: () => void } | null {
285
+ const selectedStrategy = strategy || 'auto';
286
+
287
+ // Strategy 1: Parent window canvas (best for iframe contexts)
288
+ if (selectedStrategy === 'auto' || selectedStrategy === 'parent-window') {
289
+ const parentCanvas = createParentWindowCanvas(doc);
290
+ if (parentCanvas) {
291
+ return {
292
+ canvas: parentCanvas.canvas,
293
+ cleanup: parentCanvas.cleanup
294
+ };
295
+ }
296
+ }
297
+
298
+ // Strategy 2: Lower dialog z-index (converts modal to non-modal temporarily)
299
+ if (selectedStrategy === 'auto' || selectedStrategy === 'lower-dialog') {
300
+ const canvas = doc.getElementById(canvasId) as HTMLCanvasElement;
301
+ if (canvas) {
302
+ const restore = lowerDialogZIndex(doc);
303
+ return {
304
+ canvas,
305
+ cleanup: restore
306
+ };
307
+ }
308
+ }
309
+
310
+ // Strategy 3: Make dialogs transparent (least intrusive)
311
+ if (selectedStrategy === 'auto' || selectedStrategy === 'transparent') {
312
+ const canvas = doc.getElementById(canvasId) as HTMLCanvasElement;
313
+ if (canvas) {
314
+ const restore = makeDialogsTransparent(doc);
315
+ return {
316
+ canvas,
317
+ cleanup: restore
318
+ };
319
+ }
320
+ }
321
+
322
+ return null;
323
+ }
324
+
325
+ /**
326
+ * Applies canvas layer with dialog awareness
327
+ * This is the recommended approach: hide dialogs temporarily while heatmap is active
328
+ */
329
+ export function applyCanvasLayerWithDialogAwareness(
330
+ canvas: HTMLCanvasElement,
331
+ onShow?: () => void,
332
+ onHide?: () => void
333
+ ): { show: () => void; hide: () => void } {
334
+ let restoreDialogs: (() => void) | null = null;
335
+
336
+ return {
337
+ show: () => {
338
+ // Hide dialogs when showing canvas
339
+ restoreDialogs = hideDialogsTemporarily(canvas.ownerDocument);
340
+ if (onShow) onShow();
341
+ },
342
+ hide: () => {
343
+ // Restore dialogs when hiding canvas
344
+ if (restoreDialogs) {
345
+ restoreDialogs();
346
+ restoreDialogs = null;
347
+ }
348
+ if (onHide) onHide();
349
+ }
350
+ };
351
+ }
@@ -11,6 +11,47 @@ export interface DialogRenderOptions {
11
11
  isExistingDialog: boolean;
12
12
  }
13
13
 
14
+ /**
15
+ * Pending dialogs tracking for synchronization
16
+ * Used to track when all dialogs have finished rendering
17
+ * Uses Set to avoid counting the same dialog multiple times
18
+ */
19
+ let pendingDialogs: Set<HTMLDialogElement> = new Set();
20
+ let resolveDialogsRendered: (() => void) | null = null;
21
+ let dialogsRenderedPromise: Promise<void> | null = null;
22
+
23
+ /**
24
+ * Returns a promise that resolves when all pending dialogs are rendered
25
+ */
26
+ export function waitForDialogsRendered(): Promise<void> {
27
+ if (pendingDialogs.size === 0) {
28
+ // No pending dialogs, resolve immediately
29
+ return Promise.resolve();
30
+ }
31
+
32
+ // Return existing promise if already waiting
33
+ if (dialogsRenderedPromise) {
34
+ return dialogsRenderedPromise;
35
+ }
36
+
37
+ // Create new promise
38
+ dialogsRenderedPromise = new Promise<void>((resolve) => {
39
+ resolveDialogsRendered = resolve;
40
+ });
41
+
42
+ return dialogsRenderedPromise;
43
+ }
44
+
45
+ /**
46
+ * Resets the dialog rendering state
47
+ * Should be called before starting a new render cycle
48
+ */
49
+ export function resetDialogRenderState(): void {
50
+ pendingDialogs.clear();
51
+ resolveDialogsRendered = null;
52
+ dialogsRenderedPromise = null;
53
+ }
54
+
14
55
  /**
15
56
  * Extracts dialog rendering options from node attributes
16
57
  *
@@ -45,27 +86,101 @@ export function showDialog(
45
86
  ): void {
46
87
  try {
47
88
  if (!dialogElement.isConnected) {
48
- console.warn('⚠️ Dialog not connected to DOM, skipping show');
89
+ notifyDialogComplete(dialogElement);
49
90
  return;
50
91
  }
51
-
52
- // IMPORTANT: If dialog is already open (from HTML attribute),
53
- // we need to close and reopen to ensure showModal() is called
54
- // and dialog enters top-layer properly
55
- if (dialogElement.open) {
56
- dialogElement.close();
92
+ if (!dialogElement.open) {
93
+ notifyDialogComplete(dialogElement);
94
+ return;
57
95
  }
58
96
 
97
+ dialogElement.close();
59
98
  if (isModal) {
60
99
  dialogElement.showModal();
61
100
  } else {
62
101
  dialogElement.show();
63
102
  }
103
+
104
+ // Wait for animations/transitions to complete
105
+ waitForDialogAnimation(dialogElement).then(() => {
106
+ notifyDialogComplete(dialogElement);
107
+ });
64
108
  } catch (e) {
65
109
  console.error('❌ Error showing dialog:', e);
66
110
  if (onError) {
67
111
  onError(e as Error);
68
112
  }
113
+ // Still mark as complete even on error
114
+ notifyDialogComplete(dialogElement);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Waits for dialog animations/transitions to complete
120
+ * Listens for animationend and transitionend events with timeout fallback
121
+ */
122
+ function waitForDialogAnimation(dialogElement: HTMLDialogElement): Promise<void> {
123
+ return new Promise((resolve) => {
124
+ let resolved = false;
125
+ const timeoutMs = 1000; // Maximum wait time for animations
126
+
127
+ const complete = () => {
128
+ if (resolved) return;
129
+ resolved = true;
130
+ cleanup();
131
+ resolve();
132
+ };
133
+
134
+ // Fallback timeout in case no animation events fire
135
+ const timeout = setTimeout(complete, timeoutMs);
136
+
137
+ // Listen for animation end
138
+ const onAnimationEnd = (e: AnimationEvent) => {
139
+ if (e.target === dialogElement) {
140
+ complete();
141
+ }
142
+ };
143
+
144
+ // Listen for transition end
145
+ const onTransitionEnd = (TransitionEvent: TransitionEvent) => {
146
+ if (TransitionEvent.target === dialogElement) {
147
+ complete();
148
+ }
149
+ };
150
+
151
+ const cleanup = () => {
152
+ clearTimeout(timeout);
153
+ dialogElement.removeEventListener('animationend', onAnimationEnd as EventListener);
154
+ dialogElement.removeEventListener('transitionend', onTransitionEnd as EventListener);
155
+ };
156
+
157
+ dialogElement.addEventListener('animationend', onAnimationEnd as EventListener, { once: true });
158
+ dialogElement.addEventListener('transitionend', onTransitionEnd as EventListener, { once: true });
159
+
160
+ // If no animations/transitions, resolve after one frame
161
+ requestAnimationFrame(() => {
162
+ const computedStyle = window.getComputedStyle(dialogElement);
163
+ const hasAnimation = computedStyle.animationName !== 'none';
164
+ const hasTransition = computedStyle.transitionDuration !== '0s';
165
+
166
+ if (!hasAnimation && !hasTransition) {
167
+ complete();
168
+ }
169
+ });
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Internal: Notify that a dialog has completed rendering
175
+ */
176
+ function notifyDialogComplete(dialogElement: HTMLDialogElement): void {
177
+ pendingDialogs.delete(dialogElement);
178
+
179
+ // If all dialogs are done, resolve the promise
180
+ if (pendingDialogs.size === 0 && resolveDialogsRendered) {
181
+ resolveDialogsRendered();
182
+ resolveDialogsRendered = null;
183
+ dialogsRenderedPromise = null;
69
184
  }
70
185
  }
71
186
 
@@ -98,20 +213,16 @@ export function renderDialog(
98
213
  onError?: (error: Error) => void
99
214
  ): void {
100
215
  const { isModal, shouldBeOpen, isExistingDialog } = options;
216
+ if (!shouldBeOpen) return;
101
217
 
102
- if (shouldBeOpen) {
103
- const doShow = () => showDialog(dialogElement, isModal, onError);
218
+ // Add dialog to pending set (Set automatically handles duplicates)
219
+ pendingDialogs.add(dialogElement);
104
220
 
105
- if (isExistingDialog) {
106
- // Dialog already exists, call immediately
107
- doShow();
108
- } else {
109
- // New dialog, wait for DOM insertion
110
- setTimeout(doShow, 0);
111
- }
112
- } else if (dialogElement.open) {
113
- // Dialog should be closed
114
- closeDialog(dialogElement);
221
+ const doShow = () => showDialog(dialogElement, isModal, onError);
222
+ if (isExistingDialog) {
223
+ doShow();
224
+ } else {
225
+ setTimeout(doShow, 0);
115
226
  }
116
227
  }
117
228
 
@@ -4,3 +4,4 @@
4
4
  */
5
5
 
6
6
  export * from "./dialog";
7
+ export * from "./canvas-layer";