angular-grab 0.1.1 → 0.1.2

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.
Files changed (132) hide show
  1. package/README.md +215 -0
  2. package/examples/angular-19-app/.editorconfig +17 -0
  3. package/examples/angular-19-app/.vscode/extensions.json +4 -0
  4. package/examples/angular-19-app/.vscode/launch.json +20 -0
  5. package/examples/angular-19-app/.vscode/mcp.json +9 -0
  6. package/examples/angular-19-app/.vscode/tasks.json +42 -0
  7. package/examples/angular-19-app/README.md +59 -0
  8. package/examples/angular-19-app/angular.json +74 -0
  9. package/examples/angular-19-app/package.json +44 -0
  10. package/examples/angular-19-app/public/favicon.ico +0 -0
  11. package/examples/angular-19-app/src/app/app.config.ts +13 -0
  12. package/examples/angular-19-app/src/app/app.css +37 -0
  13. package/examples/angular-19-app/src/app/app.html +25 -0
  14. package/examples/angular-19-app/src/app/app.routes.ts +3 -0
  15. package/examples/angular-19-app/src/app/app.spec.ts +23 -0
  16. package/examples/angular-19-app/src/app/app.ts +12 -0
  17. package/examples/angular-19-app/src/app/button/button.component.ts +25 -0
  18. package/examples/angular-19-app/src/app/card/card.component.ts +33 -0
  19. package/examples/angular-19-app/src/app/header/header.component.ts +31 -0
  20. package/examples/angular-19-app/src/app/popover/popover.component.ts +133 -0
  21. package/examples/angular-19-app/src/index.html +13 -0
  22. package/examples/angular-19-app/src/main.ts +6 -0
  23. package/examples/angular-19-app/src/styles.css +1 -0
  24. package/examples/angular-19-app/tsconfig.app.json +15 -0
  25. package/examples/angular-19-app/tsconfig.json +33 -0
  26. package/examples/angular-19-app/tsconfig.spec.json +15 -0
  27. package/package.json +14 -111
  28. package/packages/angular-grab/package.json +96 -0
  29. package/packages/angular-grab/src/angular/__tests__/context-builder.test.ts +216 -0
  30. package/packages/angular-grab/src/angular/angular-grab.service.ts +62 -0
  31. package/packages/angular-grab/src/angular/index.ts +13 -0
  32. package/packages/angular-grab/src/angular/provide-angular-grab.ts +22 -0
  33. package/packages/angular-grab/src/angular/resolvers/component-resolver.ts +71 -0
  34. package/packages/angular-grab/src/angular/resolvers/context-builder.ts +86 -0
  35. package/packages/angular-grab/src/angular/resolvers/ng-utils.ts +14 -0
  36. package/packages/angular-grab/src/angular/resolvers/source-resolver.ts +61 -0
  37. package/packages/angular-grab/src/builder/__tests__/builder.test.ts +72 -0
  38. package/packages/angular-grab/src/builder/builders/application/index.ts +13 -0
  39. package/packages/angular-grab/src/builder/builders/dev-server/index.ts +9 -0
  40. package/packages/angular-grab/src/builder/index.ts +3 -0
  41. package/packages/angular-grab/src/cli/__tests__/cli.test.ts +239 -0
  42. package/packages/angular-grab/src/cli/commands/init.ts +106 -0
  43. package/packages/angular-grab/src/cli/index.ts +15 -0
  44. package/packages/angular-grab/src/cli/utils/detect-project.ts +78 -0
  45. package/packages/angular-grab/src/cli/utils/modify-angular-json.ts +42 -0
  46. package/packages/angular-grab/src/cli/utils/modify-app-config.ts +42 -0
  47. package/packages/angular-grab/src/core/__tests__/generate-snippet.test.ts +149 -0
  48. package/packages/angular-grab/src/core/__tests__/plugin-registry.test.ts +286 -0
  49. package/packages/angular-grab/src/core/__tests__/store.test.ts +118 -0
  50. package/packages/angular-grab/src/core/__tests__/utils.test.ts +85 -0
  51. package/packages/angular-grab/src/core/clipboard/copy.ts +104 -0
  52. package/packages/angular-grab/src/core/clipboard/generate-snippet.ts +38 -0
  53. package/packages/angular-grab/src/core/constants.ts +10 -0
  54. package/packages/angular-grab/src/core/grab.ts +596 -0
  55. package/packages/angular-grab/src/core/index.global.ts +13 -0
  56. package/packages/angular-grab/src/core/index.ts +19 -0
  57. package/packages/angular-grab/src/core/keyboard/keyboard-handler.ts +163 -0
  58. package/packages/angular-grab/src/core/overlay/crosshair.ts +107 -0
  59. package/packages/angular-grab/src/core/overlay/freeze-overlay.ts +239 -0
  60. package/packages/angular-grab/src/core/overlay/overlay-renderer.ts +180 -0
  61. package/packages/angular-grab/src/core/overlay/select-feedback.ts +108 -0
  62. package/packages/angular-grab/src/core/overlay/toast.ts +175 -0
  63. package/packages/angular-grab/src/core/picker/element-picker.ts +114 -0
  64. package/packages/angular-grab/src/core/plugins/plugin-registry.ts +83 -0
  65. package/packages/angular-grab/src/core/store.ts +52 -0
  66. package/packages/angular-grab/src/core/toolbar/actions-menu.ts +178 -0
  67. package/packages/angular-grab/src/core/toolbar/comment-popover.ts +235 -0
  68. package/packages/angular-grab/src/core/toolbar/copy-actions.ts +98 -0
  69. package/packages/angular-grab/src/core/toolbar/history-popover.ts +245 -0
  70. package/packages/angular-grab/src/core/toolbar/theme-manager.ts +188 -0
  71. package/packages/angular-grab/src/core/toolbar/toolbar-icons.ts +29 -0
  72. package/packages/angular-grab/src/core/toolbar/toolbar-renderer.ts +239 -0
  73. package/packages/angular-grab/src/core/types.ts +139 -0
  74. package/packages/angular-grab/src/core/utils.ts +16 -0
  75. package/packages/angular-grab/src/esbuild-plugin/__tests__/transform.test.ts +174 -0
  76. package/packages/angular-grab/src/esbuild-plugin/index.ts +3 -0
  77. package/packages/angular-grab/src/esbuild-plugin/plugin.ts +29 -0
  78. package/packages/angular-grab/src/esbuild-plugin/scan.ts +105 -0
  79. package/packages/angular-grab/src/esbuild-plugin/transform.ts +152 -0
  80. package/packages/angular-grab/src/vite-plugin/__tests__/plugin.test.ts +84 -0
  81. package/packages/angular-grab/src/vite-plugin/index.ts +19 -0
  82. package/packages/angular-grab/src/webpack-plugin/__tests__/plugin.test.ts +72 -0
  83. package/packages/angular-grab/src/webpack-plugin/index.ts +2 -0
  84. package/packages/angular-grab/src/webpack-plugin/loader.ts +15 -0
  85. package/packages/angular-grab/src/webpack-plugin/plugin.ts +20 -0
  86. package/packages/angular-grab/tsconfig.json +15 -0
  87. package/packages/angular-grab/tsup.config.ts +119 -0
  88. package/pnpm-workspace.yaml +3 -0
  89. package/turbo.json +21 -0
  90. package/dist/angular/index.d.ts +0 -151
  91. package/dist/angular/index.js +0 -2811
  92. package/dist/angular/index.js.map +0 -1
  93. package/dist/builder/builders/application/index.js +0 -143
  94. package/dist/builder/builders/application/index.js.map +0 -1
  95. package/dist/builder/builders/dev-server/index.js +0 -139
  96. package/dist/builder/builders/dev-server/index.js.map +0 -1
  97. package/dist/builder/index.js +0 -2
  98. package/dist/builder/index.js.map +0 -1
  99. package/dist/builder/package.json +0 -1
  100. package/dist/cli/index.js +0 -223
  101. package/dist/cli/index.js.map +0 -1
  102. package/dist/core/index.cjs +0 -2589
  103. package/dist/core/index.cjs.map +0 -1
  104. package/dist/core/index.d.cts +0 -139
  105. package/dist/core/index.d.ts +0 -139
  106. package/dist/core/index.global.js +0 -542
  107. package/dist/core/index.js +0 -2560
  108. package/dist/core/index.js.map +0 -1
  109. package/dist/esbuild-plugin/index.cjs +0 -239
  110. package/dist/esbuild-plugin/index.cjs.map +0 -1
  111. package/dist/esbuild-plugin/index.d.cts +0 -26
  112. package/dist/esbuild-plugin/index.d.ts +0 -26
  113. package/dist/esbuild-plugin/index.js +0 -200
  114. package/dist/esbuild-plugin/index.js.map +0 -1
  115. package/dist/vite-plugin/index.d.ts +0 -7
  116. package/dist/vite-plugin/index.js +0 -128
  117. package/dist/vite-plugin/index.js.map +0 -1
  118. package/dist/webpack-plugin/index.cjs +0 -54
  119. package/dist/webpack-plugin/index.cjs.map +0 -1
  120. package/dist/webpack-plugin/index.d.cts +0 -5
  121. package/dist/webpack-plugin/index.d.ts +0 -5
  122. package/dist/webpack-plugin/index.js +0 -23
  123. package/dist/webpack-plugin/index.js.map +0 -1
  124. package/dist/webpack-plugin/loader.cjs +0 -155
  125. package/dist/webpack-plugin/loader.cjs.map +0 -1
  126. package/dist/webpack-plugin/loader.d.cts +0 -3
  127. package/dist/webpack-plugin/loader.d.ts +0 -3
  128. package/dist/webpack-plugin/loader.js +0 -122
  129. package/dist/webpack-plugin/loader.js.map +0 -1
  130. /package/{builders.json → packages/angular-grab/builders.json} +0 -0
  131. /package/{dist → packages/angular-grab/src}/builder/builders/application/schema.json +0 -0
  132. /package/{dist → packages/angular-grab/src}/builder/builders/dev-server/schema.json +0 -0
@@ -0,0 +1,163 @@
1
+ export interface ParsedKey {
2
+ key: string;
3
+ meta: boolean;
4
+ ctrl: boolean;
5
+ shift: boolean;
6
+ alt: boolean;
7
+ }
8
+
9
+ export interface KeyboardHandler {
10
+ start(): void;
11
+ stop(): void;
12
+ dispose(): void;
13
+ }
14
+
15
+ export interface KeyboardHandlerDeps {
16
+ getActivationKey: () => string;
17
+ getActivationMode: () => 'hold' | 'toggle';
18
+ getKeyHoldDuration: () => number;
19
+ getEnableInInputs: () => boolean;
20
+ onActivate: () => void;
21
+ onDeactivate: () => void;
22
+ isActive: () => boolean;
23
+ }
24
+
25
+ export function isMac(): boolean {
26
+ if (typeof navigator === 'undefined') return false;
27
+ const uaData = (navigator as any).userAgentData;
28
+ if (uaData?.platform) return /mac/i.test(uaData.platform);
29
+ return /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent);
30
+ }
31
+
32
+ export function parseKeyCombo(combo: string): ParsedKey {
33
+ const parts = combo.split('+').map((s) => s.trim());
34
+ const result: ParsedKey = {
35
+ key: '',
36
+ meta: false,
37
+ ctrl: false,
38
+ shift: false,
39
+ alt: false,
40
+ };
41
+
42
+ for (const part of parts) {
43
+ const lower = part.toLowerCase();
44
+ if (lower === 'meta' || lower === 'cmd' || lower === 'command') {
45
+ result.meta = true;
46
+ } else if (lower === 'ctrl' || lower === 'control') {
47
+ result.ctrl = true;
48
+ } else if (lower === 'shift') {
49
+ result.shift = true;
50
+ } else if (lower === 'alt' || lower === 'option') {
51
+ result.alt = true;
52
+ } else {
53
+ result.key = lower;
54
+ }
55
+ }
56
+
57
+ return result;
58
+ }
59
+
60
+ function matchesCombo(e: KeyboardEvent, parsed: ParsedKey): boolean {
61
+ if (parsed.meta && !e.metaKey) return false;
62
+ if (parsed.ctrl && !e.ctrlKey) return false;
63
+ if (parsed.shift && !e.shiftKey) return false;
64
+ if (parsed.alt && !e.altKey) return false;
65
+
66
+ return e.key.toLowerCase() === parsed.key;
67
+ }
68
+
69
+ function isInputElement(el: EventTarget | null): boolean {
70
+ if (!el || !(el instanceof HTMLElement)) return false;
71
+ const tag = el.tagName;
72
+ return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
73
+ }
74
+
75
+ export function createKeyboardHandler(deps: KeyboardHandlerDeps): KeyboardHandler {
76
+ let holdTimer: ReturnType<typeof setTimeout> | null = null;
77
+ let holdActivated = false;
78
+ let listening = false;
79
+
80
+ function handleKeyDown(e: KeyboardEvent): void {
81
+ if (!deps.getEnableInInputs() && isInputElement(e.target)) return;
82
+
83
+ const parsed = parseKeyCombo(deps.getActivationKey());
84
+ if (!matchesCombo(e, parsed)) return;
85
+
86
+ const mode = deps.getActivationMode();
87
+ const holdDuration = deps.getKeyHoldDuration();
88
+
89
+ if (mode === 'hold') {
90
+ e.preventDefault();
91
+
92
+ if (holdActivated) return;
93
+
94
+ if (holdDuration > 0) {
95
+ if (holdTimer) return;
96
+ holdTimer = setTimeout(() => {
97
+ holdActivated = true;
98
+ deps.onActivate();
99
+ }, holdDuration);
100
+ } else {
101
+ holdActivated = true;
102
+ deps.onActivate();
103
+ }
104
+ } else {
105
+ // toggle mode
106
+ e.preventDefault();
107
+ }
108
+ }
109
+
110
+ function handleKeyUp(e: KeyboardEvent): void {
111
+ const parsed = parseKeyCombo(deps.getActivationKey());
112
+
113
+ // For key-up we check if the released key matches the main key
114
+ if (e.key.toLowerCase() !== parsed.key) return;
115
+
116
+ const mode = deps.getActivationMode();
117
+
118
+ if (mode === 'hold') {
119
+ if (holdTimer) {
120
+ clearTimeout(holdTimer);
121
+ holdTimer = null;
122
+ }
123
+ if (holdActivated) {
124
+ holdActivated = false;
125
+ deps.onDeactivate();
126
+ }
127
+ } else {
128
+ // toggle mode: toggle on key-up so we don't double-fire
129
+ if (deps.isActive()) {
130
+ deps.onDeactivate();
131
+ } else {
132
+ deps.onActivate();
133
+ }
134
+ }
135
+ }
136
+
137
+ return {
138
+ start(): void {
139
+ if (listening) return;
140
+ listening = true;
141
+ document.addEventListener('keydown', handleKeyDown, true);
142
+ document.addEventListener('keyup', handleKeyUp, true);
143
+ },
144
+
145
+ stop(): void {
146
+ if (!listening) return;
147
+ listening = false;
148
+
149
+ if (holdTimer) {
150
+ clearTimeout(holdTimer);
151
+ holdTimer = null;
152
+ }
153
+ holdActivated = false;
154
+
155
+ document.removeEventListener('keydown', handleKeyDown, true);
156
+ document.removeEventListener('keyup', handleKeyUp, true);
157
+ },
158
+
159
+ dispose(): void {
160
+ this.stop();
161
+ },
162
+ };
163
+ }
@@ -0,0 +1,107 @@
1
+ import { Z_INDEX_CROSSHAIR } from '../constants';
2
+
3
+ const CROSSHAIR_STYLE_ID = '__ag-crosshair-styles__';
4
+ const H_LINE_ID = '__ag-crosshair-h__';
5
+ const V_LINE_ID = '__ag-crosshair-v__';
6
+
7
+ export interface Crosshair {
8
+ activate(): void;
9
+ deactivate(): void;
10
+ isCrosshairElement(el: Element): boolean;
11
+ dispose(): void;
12
+ }
13
+
14
+ export function createCrosshair(): Crosshair {
15
+ let hLine: HTMLDivElement | null = null;
16
+ let vLine: HTMLDivElement | null = null;
17
+ let listening = false;
18
+
19
+ function injectStyles(): void {
20
+ if (document.getElementById(CROSSHAIR_STYLE_ID)) return;
21
+
22
+ const style = document.createElement('style');
23
+ style.id = CROSSHAIR_STYLE_ID;
24
+ style.textContent = `
25
+ .ag-crosshair-line {
26
+ position: fixed;
27
+ pointer-events: none;
28
+ z-index: ${Z_INDEX_CROSSHAIR};
29
+ background: var(--ag-accent, #3b82f6);
30
+ opacity: 0.25;
31
+ transition: none;
32
+ }
33
+ #${H_LINE_ID} {
34
+ left: 0;
35
+ right: 0;
36
+ height: 1px;
37
+ }
38
+ #${V_LINE_ID} {
39
+ top: 0;
40
+ bottom: 0;
41
+ width: 1px;
42
+ }
43
+ body.ag-crosshair-active {
44
+ cursor: crosshair !important;
45
+ }
46
+ `;
47
+ document.head.appendChild(style);
48
+ }
49
+
50
+ function ensureElements(): void {
51
+ if (!hLine) {
52
+ injectStyles();
53
+ hLine = document.createElement('div');
54
+ hLine.id = H_LINE_ID;
55
+ hLine.className = 'ag-crosshair-line';
56
+ document.body.appendChild(hLine);
57
+ }
58
+ if (!vLine) {
59
+ vLine = document.createElement('div');
60
+ vLine.id = V_LINE_ID;
61
+ vLine.className = 'ag-crosshair-line';
62
+ document.body.appendChild(vLine);
63
+ }
64
+ }
65
+
66
+ function handleMouseMove(e: MouseEvent): void {
67
+ if (hLine) {
68
+ hLine.style.top = `${e.clientY}px`;
69
+ }
70
+ if (vLine) {
71
+ vLine.style.left = `${e.clientX}px`;
72
+ }
73
+ }
74
+
75
+ return {
76
+ activate(): void {
77
+ if (listening) return;
78
+ listening = true;
79
+ ensureElements();
80
+ document.body.classList.add('ag-crosshair-active');
81
+ document.addEventListener('mousemove', handleMouseMove, true);
82
+ },
83
+
84
+ deactivate(): void {
85
+ if (!listening) return;
86
+ listening = false;
87
+ document.body.classList.remove('ag-crosshair-active');
88
+ document.removeEventListener('mousemove', handleMouseMove, true);
89
+ if (hLine) hLine.style.top = '-10px';
90
+ if (vLine) vLine.style.left = '-10px';
91
+ },
92
+
93
+ isCrosshairElement(el: Element): boolean {
94
+ return el === hLine || el === vLine
95
+ || el.id === H_LINE_ID || el.id === V_LINE_ID;
96
+ },
97
+
98
+ dispose(): void {
99
+ this.deactivate();
100
+ hLine?.remove();
101
+ vLine?.remove();
102
+ document.getElementById(CROSSHAIR_STYLE_ID)?.remove();
103
+ hLine = null;
104
+ vLine = null;
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,239 @@
1
+ import { Z_INDEX_FREEZE } from '../constants';
2
+
3
+ const FREEZE_ID = '__ag-freeze-overlay__';
4
+ const FREEZE_STYLE_ID = '__ag-freeze-styles__';
5
+ const HOVER_STYLE_ID = '__ag-freeze-hover-styles__';
6
+ const ANIM_STYLE_ID = '__ag-freeze-anim-styles__';
7
+ const HOVER_ATTR = 'data-ag-hover';
8
+
9
+ /**
10
+ * Events to block during freeze to prevent hover state changes.
11
+ * Adapted from react-grab's freeze-pseudo-states.ts
12
+ */
13
+ const MOUSE_EVENTS_TO_BLOCK = [
14
+ 'mouseenter', 'mouseleave', 'mouseover', 'mouseout',
15
+ 'pointerenter', 'pointerleave', 'pointerover', 'pointerout',
16
+ ] as const;
17
+
18
+ const FOCUS_EVENTS_TO_BLOCK = ['focus', 'blur', 'focusin', 'focusout'] as const;
19
+
20
+ export interface FreezeOverlay {
21
+ show(hoveredElement?: Element | null): void;
22
+ hide(): void;
23
+ isVisible(): boolean;
24
+ isFreezeElement(el: Element): boolean;
25
+ getElement(): HTMLDivElement | null;
26
+ dispose(): void;
27
+ }
28
+
29
+ export function createFreezeOverlay(): FreezeOverlay {
30
+ let overlay: HTMLDivElement | null = null;
31
+ let visible = false;
32
+ let hoverStyleEl: HTMLStyleElement | null = null;
33
+ let animStyleEl: HTMLStyleElement | null = null;
34
+ let markedElements: Element[] = [];
35
+
36
+ function injectStyles(): void {
37
+ if (document.getElementById(FREEZE_STYLE_ID)) return;
38
+
39
+ const style = document.createElement('style');
40
+ style.id = FREEZE_STYLE_ID;
41
+ style.textContent = `
42
+ #${FREEZE_ID} {
43
+ position: fixed;
44
+ top: 0;
45
+ left: 0;
46
+ width: 100vw;
47
+ height: 100vh;
48
+ z-index: ${Z_INDEX_FREEZE};
49
+ pointer-events: auto;
50
+ background: transparent;
51
+ }
52
+ `;
53
+ document.head.appendChild(style);
54
+ }
55
+
56
+ function ensureOverlay(): HTMLDivElement {
57
+ if (overlay) return overlay;
58
+
59
+ injectStyles();
60
+ overlay = document.createElement('div');
61
+ overlay.id = FREEZE_ID;
62
+ overlay.style.display = 'none';
63
+ document.body.appendChild(overlay);
64
+ return overlay;
65
+ }
66
+
67
+ // --- Event blocking (from react-grab's freeze-pseudo-states.ts) ---
68
+
69
+ const stopEvent = (e: Event): void => {
70
+ e.stopImmediatePropagation();
71
+ };
72
+
73
+ const preventFocusChange = (e: Event): void => {
74
+ e.preventDefault();
75
+ e.stopImmediatePropagation();
76
+ };
77
+
78
+ function blockEvents(): void {
79
+ for (const type of MOUSE_EVENTS_TO_BLOCK) {
80
+ document.addEventListener(type, stopEvent, true);
81
+ }
82
+ for (const type of FOCUS_EVENTS_TO_BLOCK) {
83
+ document.addEventListener(type, preventFocusChange, true);
84
+ }
85
+ }
86
+
87
+ function unblockEvents(): void {
88
+ for (const type of MOUSE_EVENTS_TO_BLOCK) {
89
+ document.removeEventListener(type, stopEvent, true);
90
+ }
91
+ for (const type of FOCUS_EVENTS_TO_BLOCK) {
92
+ document.removeEventListener(type, preventFocusChange, true);
93
+ }
94
+ }
95
+
96
+ // --- Hover preservation via CSS rule cloning ---
97
+
98
+ /** Mark the hovered element and all ancestors with [data-ag-hover]. */
99
+ function markHoverChain(element: Element): void {
100
+ let current: Element | null = element;
101
+ while (current && current !== document.documentElement) {
102
+ current.setAttribute(HOVER_ATTR, '');
103
+ markedElements.push(current);
104
+ current = current.parentElement;
105
+ }
106
+ }
107
+
108
+ function clearHoverMarks(): void {
109
+ for (const el of markedElements) {
110
+ el.removeAttribute(HOVER_ATTR);
111
+ }
112
+ markedElements = [];
113
+ }
114
+
115
+ /**
116
+ * Walk all stylesheets and clone :hover rules as [data-ag-hover] rules.
117
+ * This preserves hover-dependent visibility of child elements
118
+ * (e.g. `.trigger:hover .tooltip { display: block }`) which
119
+ * computed-style snapshotting alone cannot handle.
120
+ */
121
+ function injectHoverRules(): void {
122
+ if (hoverStyleEl) return;
123
+
124
+ const cloned: string[] = [];
125
+
126
+ for (const sheet of Array.from(document.styleSheets)) {
127
+ let rules: CSSRuleList;
128
+ try {
129
+ rules = sheet.cssRules;
130
+ } catch {
131
+ continue; // cross-origin stylesheet
132
+ }
133
+ collectHoverRules(rules, cloned);
134
+ }
135
+
136
+ if (cloned.length === 0) return;
137
+
138
+ hoverStyleEl = document.createElement('style');
139
+ hoverStyleEl.id = HOVER_STYLE_ID;
140
+ hoverStyleEl.textContent = cloned.join('\n');
141
+ document.head.appendChild(hoverStyleEl);
142
+ }
143
+
144
+ function collectHoverRules(rules: CSSRuleList, out: string[]): void {
145
+ for (const rule of Array.from(rules)) {
146
+ if (rule instanceof CSSStyleRule) {
147
+ if (rule.selectorText.includes(':hover')) {
148
+ const newSelector = rule.selectorText.replace(/:hover/g, `[${HOVER_ATTR}]`);
149
+ out.push(`${newSelector} { ${rule.style.cssText} }`);
150
+ }
151
+ } else if (rule instanceof CSSMediaRule) {
152
+ const inner: string[] = [];
153
+ collectHoverRules(rule.cssRules, inner);
154
+ if (inner.length > 0) {
155
+ out.push(`@media ${rule.conditionText} { ${inner.join('\n')} }`);
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ function removeHoverRules(): void {
162
+ hoverStyleEl?.remove();
163
+ hoverStyleEl = null;
164
+ }
165
+
166
+ // --- Animation freezing (from react-grab's freeze-animations.ts) ---
167
+
168
+ function freezeAnimations(): void {
169
+ if (animStyleEl) return;
170
+
171
+ animStyleEl = document.createElement('style');
172
+ animStyleEl.id = ANIM_STYLE_ID;
173
+ animStyleEl.textContent = `
174
+ *, *::before, *::after {
175
+ animation-play-state: paused !important;
176
+ transition: none !important;
177
+ }
178
+ `;
179
+ document.head.appendChild(animStyleEl);
180
+ }
181
+
182
+ function unfreezeAnimations(): void {
183
+ animStyleEl?.remove();
184
+ animStyleEl = null;
185
+ }
186
+
187
+ return {
188
+ show(hoveredElement?: Element | null): void {
189
+ // 1. Block mouse/focus events to prevent hover state changes
190
+ blockEvents();
191
+
192
+ // 2. Preserve hover state via CSS rule cloning BEFORE overlay steals hover
193
+ if (hoveredElement) {
194
+ markHoverChain(hoveredElement);
195
+ injectHoverRules();
196
+ }
197
+
198
+ // 3. Freeze animations so nothing moves while page is frozen
199
+ freezeAnimations();
200
+
201
+ // 4. Show overlay to block clicks/scrolls
202
+ const el = ensureOverlay();
203
+ el.style.display = 'block';
204
+ visible = true;
205
+ },
206
+
207
+ hide(): void {
208
+ if (overlay) overlay.style.display = 'none';
209
+ visible = false;
210
+ clearHoverMarks();
211
+ removeHoverRules();
212
+ unfreezeAnimations();
213
+ unblockEvents();
214
+ },
215
+
216
+ isVisible(): boolean {
217
+ return visible;
218
+ },
219
+
220
+ isFreezeElement(el: Element): boolean {
221
+ return el === overlay || el.id === FREEZE_ID;
222
+ },
223
+
224
+ getElement(): HTMLDivElement | null {
225
+ return overlay;
226
+ },
227
+
228
+ dispose(): void {
229
+ clearHoverMarks();
230
+ removeHoverRules();
231
+ unfreezeAnimations();
232
+ unblockEvents();
233
+ overlay?.remove();
234
+ document.getElementById(FREEZE_STYLE_ID)?.remove();
235
+ overlay = null;
236
+ visible = false;
237
+ },
238
+ };
239
+ }
@@ -0,0 +1,180 @@
1
+ import { Z_INDEX_OVERLAY, Z_INDEX_LABEL } from '../constants';
2
+
3
+ const OVERLAY_ID = '__ag-overlay__';
4
+ const LABEL_ID = '__ag-label__';
5
+ const STYLE_ID = '__ag-styles__';
6
+
7
+ export interface OverlayRenderer {
8
+ show(element: Element, componentName: string | null, sourcePath?: string | null, cssClasses?: string[]): void;
9
+ hide(): void;
10
+ isOverlayElement(el: Element): boolean;
11
+ dispose(): void;
12
+ }
13
+
14
+ export function createOverlayRenderer(): OverlayRenderer {
15
+ let overlay: HTMLDivElement | null = null;
16
+ let label: HTMLDivElement | null = null;
17
+ let rafId: number | null = null;
18
+ let currentElement: Element | null = null;
19
+ let currentComponentName: string | null = null;
20
+ let currentSourcePath: string | null = null;
21
+ let currentCssClasses: string[] = [];
22
+
23
+ function injectStyles(): void {
24
+ if (document.getElementById(STYLE_ID)) return;
25
+
26
+ const style = document.createElement('style');
27
+ style.id = STYLE_ID;
28
+ style.textContent = `
29
+ #${OVERLAY_ID} {
30
+ position: fixed;
31
+ pointer-events: none;
32
+ z-index: ${Z_INDEX_OVERLAY};
33
+ border: 2px solid var(--ag-overlay-border, #3b82f6);
34
+ background: var(--ag-overlay-bg, rgba(59, 130, 246, 0.1));
35
+ transition: top 0.05s ease, left 0.05s ease, width 0.05s ease, height 0.05s ease;
36
+ box-sizing: border-box;
37
+ }
38
+ #${LABEL_ID} {
39
+ position: fixed;
40
+ pointer-events: none;
41
+ z-index: ${Z_INDEX_LABEL};
42
+ background: var(--ag-label-bg, #3b82f6);
43
+ color: var(--ag-label-text, #fff);
44
+ font: 11px/1.4 monospace;
45
+ padding: 2px 6px;
46
+ border-radius: 3px;
47
+ white-space: nowrap;
48
+ box-sizing: border-box;
49
+ max-width: 100vw;
50
+ overflow: hidden;
51
+ text-overflow: ellipsis;
52
+ }
53
+ `;
54
+ document.head.appendChild(style);
55
+ }
56
+
57
+ function ensureElements(): void {
58
+ if (!overlay) {
59
+ injectStyles();
60
+ overlay = document.createElement('div');
61
+ overlay.id = OVERLAY_ID;
62
+ document.body.appendChild(overlay);
63
+ }
64
+ if (!label) {
65
+ label = document.createElement('div');
66
+ label.id = LABEL_ID;
67
+ document.body.appendChild(label);
68
+ }
69
+ }
70
+
71
+ function positionOverlay(): void {
72
+ if (!currentElement || !overlay || !label) return;
73
+
74
+ const rect = currentElement.getBoundingClientRect();
75
+
76
+ // Hide overlay if element is detached or has zero dimensions
77
+ if (rect.width === 0 && rect.height === 0 && !currentElement.isConnected) {
78
+ overlay.style.display = 'none';
79
+ label.style.display = 'none';
80
+ return;
81
+ }
82
+
83
+ overlay.style.top = `${rect.top}px`;
84
+ overlay.style.left = `${rect.left}px`;
85
+ overlay.style.width = `${rect.width}px`;
86
+ overlay.style.height = `${rect.height}px`;
87
+ overlay.style.display = 'block';
88
+
89
+ const tag = currentElement.tagName.toLowerCase();
90
+ let labelText = `<${tag}>`;
91
+ if (currentCssClasses.length > 0) {
92
+ labelText += ` .${currentCssClasses.join('.')}`;
93
+ }
94
+ if (currentComponentName) {
95
+ labelText += ` in ${currentComponentName}`;
96
+ }
97
+ if (currentSourcePath) {
98
+ labelText += ` \u2014 ${currentSourcePath}`;
99
+ }
100
+ label.textContent = labelText;
101
+
102
+ // Position label above the element, or below if no room
103
+ const labelHeight = 20;
104
+ const gap = 4;
105
+ let labelTop = rect.top - labelHeight - gap;
106
+ if (labelTop < 0) {
107
+ labelTop = rect.bottom + gap;
108
+ }
109
+
110
+ // Clamp label horizontally to viewport
111
+ let labelLeft = rect.left;
112
+ label.style.top = `${labelTop}px`;
113
+ label.style.left = `${labelLeft}px`;
114
+ label.style.display = 'block';
115
+
116
+ // After rendering, check if it overflows right edge
117
+ const labelRect = label.getBoundingClientRect();
118
+ const viewportWidth = document.documentElement.clientWidth;
119
+ if (labelRect.right > viewportWidth) {
120
+ labelLeft = Math.max(0, viewportWidth - labelRect.width);
121
+ label.style.left = `${labelLeft}px`;
122
+ }
123
+ if (labelLeft < 0) {
124
+ label.style.left = '0px';
125
+ }
126
+ }
127
+
128
+ function trackPosition(): void {
129
+ positionOverlay();
130
+ rafId = requestAnimationFrame(trackPosition);
131
+ }
132
+
133
+ function stopTracking(): void {
134
+ if (rafId !== null) {
135
+ cancelAnimationFrame(rafId);
136
+ rafId = null;
137
+ }
138
+ }
139
+
140
+ return {
141
+ show(element: Element, componentName: string | null, sourcePath?: string | null, cssClasses?: string[]): void {
142
+ ensureElements();
143
+ currentElement = element;
144
+ currentComponentName = componentName;
145
+ currentSourcePath = sourcePath ?? null;
146
+ currentCssClasses = cssClasses ?? [];
147
+ stopTracking();
148
+ trackPosition();
149
+ },
150
+
151
+ hide(): void {
152
+ stopTracking();
153
+ currentElement = null;
154
+ currentComponentName = null;
155
+ currentSourcePath = null;
156
+ currentCssClasses = [];
157
+
158
+ if (overlay) overlay.style.display = 'none';
159
+ if (label) label.style.display = 'none';
160
+ },
161
+
162
+ isOverlayElement(el: Element): boolean {
163
+ return el === overlay || el === label || el.id === OVERLAY_ID || el.id === LABEL_ID;
164
+ },
165
+
166
+ dispose(): void {
167
+ stopTracking();
168
+ currentElement = null;
169
+ currentComponentName = null;
170
+ currentSourcePath = null;
171
+ currentCssClasses = [];
172
+
173
+ overlay?.remove();
174
+ label?.remove();
175
+ document.getElementById(STYLE_ID)?.remove();
176
+ overlay = null;
177
+ label = null;
178
+ },
179
+ };
180
+ }