angular-grab 0.1.1 → 0.1.3

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 (138) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.playwright-mcp/console-2026-03-07T02-49-38-061Z.log +37 -0
  3. package/.playwright-mcp/console-2026-03-07T02-51-03-493Z.log +26 -0
  4. package/.playwright-mcp/console-2026-03-07T02-51-25-431Z.log +15 -0
  5. package/.playwright-mcp/console-2026-03-07T02-52-02-980Z.log +199 -0
  6. package/.playwright-mcp/page-2026-03-07T02-52-09-791Z.png +0 -0
  7. package/README.md +215 -0
  8. package/examples/angular-19-app/.editorconfig +17 -0
  9. package/examples/angular-19-app/.vscode/extensions.json +4 -0
  10. package/examples/angular-19-app/.vscode/launch.json +20 -0
  11. package/examples/angular-19-app/.vscode/mcp.json +9 -0
  12. package/examples/angular-19-app/.vscode/tasks.json +42 -0
  13. package/examples/angular-19-app/README.md +59 -0
  14. package/examples/angular-19-app/angular.json +79 -0
  15. package/examples/angular-19-app/package.json +42 -0
  16. package/examples/angular-19-app/public/favicon.ico +0 -0
  17. package/examples/angular-19-app/src/app/app.config.ts +13 -0
  18. package/examples/angular-19-app/src/app/app.css +37 -0
  19. package/examples/angular-19-app/src/app/app.html +25 -0
  20. package/examples/angular-19-app/src/app/app.routes.ts +3 -0
  21. package/examples/angular-19-app/src/app/app.spec.ts +23 -0
  22. package/examples/angular-19-app/src/app/app.ts +12 -0
  23. package/examples/angular-19-app/src/app/button/button.component.ts +25 -0
  24. package/examples/angular-19-app/src/app/card/card.component.ts +33 -0
  25. package/examples/angular-19-app/src/app/header/header.component.ts +31 -0
  26. package/examples/angular-19-app/src/app/popover/popover.component.ts +133 -0
  27. package/examples/angular-19-app/src/index.html +13 -0
  28. package/examples/angular-19-app/src/main.ts +6 -0
  29. package/examples/angular-19-app/src/styles.css +1 -0
  30. package/examples/angular-19-app/tsconfig.app.json +15 -0
  31. package/examples/angular-19-app/tsconfig.json +33 -0
  32. package/examples/angular-19-app/tsconfig.spec.json +15 -0
  33. package/package.json +14 -111
  34. package/packages/angular-grab/package.json +96 -0
  35. package/packages/angular-grab/src/angular/__tests__/context-builder.test.ts +216 -0
  36. package/packages/angular-grab/src/angular/angular-grab.service.ts +62 -0
  37. package/packages/angular-grab/src/angular/index.ts +13 -0
  38. package/packages/angular-grab/src/angular/provide-angular-grab.ts +22 -0
  39. package/packages/angular-grab/src/angular/resolvers/component-resolver.ts +71 -0
  40. package/packages/angular-grab/src/angular/resolvers/context-builder.ts +86 -0
  41. package/packages/angular-grab/src/angular/resolvers/ng-utils.ts +14 -0
  42. package/packages/angular-grab/src/angular/resolvers/source-resolver.ts +61 -0
  43. package/packages/angular-grab/src/builder/__tests__/builder.test.ts +72 -0
  44. package/packages/angular-grab/src/builder/builders/application/index.ts +13 -0
  45. package/packages/angular-grab/src/builder/builders/dev-server/index.ts +9 -0
  46. package/packages/angular-grab/src/builder/index.ts +3 -0
  47. package/packages/angular-grab/src/cli/__tests__/cli.test.ts +239 -0
  48. package/packages/angular-grab/src/cli/commands/init.ts +106 -0
  49. package/packages/angular-grab/src/cli/index.ts +15 -0
  50. package/packages/angular-grab/src/cli/utils/detect-project.ts +78 -0
  51. package/packages/angular-grab/src/cli/utils/modify-angular-json.ts +42 -0
  52. package/packages/angular-grab/src/cli/utils/modify-app-config.ts +42 -0
  53. package/packages/angular-grab/src/core/__tests__/generate-snippet.test.ts +149 -0
  54. package/packages/angular-grab/src/core/__tests__/plugin-registry.test.ts +286 -0
  55. package/packages/angular-grab/src/core/__tests__/store.test.ts +118 -0
  56. package/packages/angular-grab/src/core/__tests__/utils.test.ts +85 -0
  57. package/packages/angular-grab/src/core/clipboard/copy.ts +104 -0
  58. package/packages/angular-grab/src/core/clipboard/generate-snippet.ts +38 -0
  59. package/packages/angular-grab/src/core/constants.ts +10 -0
  60. package/packages/angular-grab/src/core/grab.ts +596 -0
  61. package/packages/angular-grab/src/core/index.global.ts +13 -0
  62. package/packages/angular-grab/src/core/index.ts +19 -0
  63. package/packages/angular-grab/src/core/keyboard/keyboard-handler.ts +163 -0
  64. package/packages/angular-grab/src/core/overlay/crosshair.ts +107 -0
  65. package/packages/angular-grab/src/core/overlay/freeze-overlay.ts +239 -0
  66. package/packages/angular-grab/src/core/overlay/overlay-renderer.ts +180 -0
  67. package/packages/angular-grab/src/core/overlay/select-feedback.ts +108 -0
  68. package/packages/angular-grab/src/core/overlay/toast.ts +175 -0
  69. package/packages/angular-grab/src/core/picker/element-picker.ts +114 -0
  70. package/packages/angular-grab/src/core/plugins/plugin-registry.ts +83 -0
  71. package/packages/angular-grab/src/core/store.ts +52 -0
  72. package/packages/angular-grab/src/core/toolbar/actions-menu.ts +178 -0
  73. package/packages/angular-grab/src/core/toolbar/comment-popover.ts +235 -0
  74. package/packages/angular-grab/src/core/toolbar/copy-actions.ts +98 -0
  75. package/packages/angular-grab/src/core/toolbar/history-popover.ts +245 -0
  76. package/packages/angular-grab/src/core/toolbar/theme-manager.ts +188 -0
  77. package/packages/angular-grab/src/core/toolbar/toolbar-icons.ts +29 -0
  78. package/packages/angular-grab/src/core/toolbar/toolbar-renderer.ts +239 -0
  79. package/packages/angular-grab/src/core/types.ts +139 -0
  80. package/packages/angular-grab/src/core/utils.ts +16 -0
  81. package/packages/angular-grab/src/esbuild-plugin/__tests__/transform.test.ts +174 -0
  82. package/packages/angular-grab/src/esbuild-plugin/index.ts +3 -0
  83. package/packages/angular-grab/src/esbuild-plugin/plugin.ts +29 -0
  84. package/packages/angular-grab/src/esbuild-plugin/scan.ts +105 -0
  85. package/packages/angular-grab/src/esbuild-plugin/transform.ts +152 -0
  86. package/packages/angular-grab/src/vite-plugin/__tests__/plugin.test.ts +84 -0
  87. package/packages/angular-grab/src/vite-plugin/index.ts +19 -0
  88. package/packages/angular-grab/src/webpack-plugin/__tests__/plugin.test.ts +72 -0
  89. package/packages/angular-grab/src/webpack-plugin/index.ts +2 -0
  90. package/packages/angular-grab/src/webpack-plugin/loader.ts +15 -0
  91. package/packages/angular-grab/src/webpack-plugin/plugin.ts +20 -0
  92. package/packages/angular-grab/tsconfig.json +15 -0
  93. package/packages/angular-grab/tsup.config.ts +119 -0
  94. package/pnpm-workspace.yaml +3 -0
  95. package/turbo.json +21 -0
  96. package/dist/angular/index.d.ts +0 -151
  97. package/dist/angular/index.js +0 -2811
  98. package/dist/angular/index.js.map +0 -1
  99. package/dist/builder/builders/application/index.js +0 -143
  100. package/dist/builder/builders/application/index.js.map +0 -1
  101. package/dist/builder/builders/dev-server/index.js +0 -139
  102. package/dist/builder/builders/dev-server/index.js.map +0 -1
  103. package/dist/builder/index.js +0 -2
  104. package/dist/builder/index.js.map +0 -1
  105. package/dist/builder/package.json +0 -1
  106. package/dist/cli/index.js +0 -223
  107. package/dist/cli/index.js.map +0 -1
  108. package/dist/core/index.cjs +0 -2589
  109. package/dist/core/index.cjs.map +0 -1
  110. package/dist/core/index.d.cts +0 -139
  111. package/dist/core/index.d.ts +0 -139
  112. package/dist/core/index.global.js +0 -542
  113. package/dist/core/index.js +0 -2560
  114. package/dist/core/index.js.map +0 -1
  115. package/dist/esbuild-plugin/index.cjs +0 -239
  116. package/dist/esbuild-plugin/index.cjs.map +0 -1
  117. package/dist/esbuild-plugin/index.d.cts +0 -26
  118. package/dist/esbuild-plugin/index.d.ts +0 -26
  119. package/dist/esbuild-plugin/index.js +0 -200
  120. package/dist/esbuild-plugin/index.js.map +0 -1
  121. package/dist/vite-plugin/index.d.ts +0 -7
  122. package/dist/vite-plugin/index.js +0 -128
  123. package/dist/vite-plugin/index.js.map +0 -1
  124. package/dist/webpack-plugin/index.cjs +0 -54
  125. package/dist/webpack-plugin/index.cjs.map +0 -1
  126. package/dist/webpack-plugin/index.d.cts +0 -5
  127. package/dist/webpack-plugin/index.d.ts +0 -5
  128. package/dist/webpack-plugin/index.js +0 -23
  129. package/dist/webpack-plugin/index.js.map +0 -1
  130. package/dist/webpack-plugin/loader.cjs +0 -155
  131. package/dist/webpack-plugin/loader.cjs.map +0 -1
  132. package/dist/webpack-plugin/loader.d.cts +0 -3
  133. package/dist/webpack-plugin/loader.d.ts +0 -3
  134. package/dist/webpack-plugin/loader.js +0 -122
  135. package/dist/webpack-plugin/loader.js.map +0 -1
  136. /package/{builders.json → packages/angular-grab/builders.json} +0 -0
  137. /package/{dist → packages/angular-grab/src}/builder/builders/application/schema.json +0 -0
  138. /package/{dist → packages/angular-grab/src}/builder/builders/dev-server/schema.json +0 -0
@@ -0,0 +1,235 @@
1
+ import { Z_INDEX_POPOVER, TOOLBAR_POPOVER_OFFSET } from '../constants';
2
+
3
+ const POPOVER_ID = '__ag-comment-popover__';
4
+ const STYLE_ID = '__ag-comment-styles__';
5
+
6
+ export interface CommentPopover {
7
+ show(): void;
8
+ hide(): void;
9
+ isVisible(): boolean;
10
+ isPopoverElement(el: Element): boolean;
11
+ dispose(): void;
12
+ }
13
+
14
+ export interface CommentPopoverCallbacks {
15
+ onSubmit: (comment: string) => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ export function createCommentPopover(callbacks: CommentPopoverCallbacks): CommentPopover {
20
+ let popover: HTMLDivElement | null = null;
21
+ let textarea: HTMLTextAreaElement | null = null;
22
+ let visible = false;
23
+ let keydownHandler: ((e: KeyboardEvent) => void) | null = null;
24
+
25
+ function injectStyles(): void {
26
+ if (document.getElementById(STYLE_ID)) return;
27
+
28
+ const style = document.createElement('style');
29
+ style.id = STYLE_ID;
30
+ style.textContent = `
31
+ #${POPOVER_ID} {
32
+ position: fixed;
33
+ bottom: ${TOOLBAR_POPOVER_OFFSET};
34
+ left: 50%;
35
+ transform: translateX(-50%);
36
+ z-index: ${Z_INDEX_POPOVER};
37
+ background: var(--ag-popover-bg, #0f172a);
38
+ border: 1px solid var(--ag-popover-border, #1e293b);
39
+ border-radius: 12px;
40
+ box-shadow: 0 8px 24px var(--ag-popover-shadow, rgba(0, 0, 0, 0.5));
41
+ width: 340px;
42
+ padding: 14px;
43
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
44
+ opacity: 0;
45
+ visibility: hidden;
46
+ transition: opacity 0.15s ease, visibility 0.15s ease;
47
+ pointer-events: auto;
48
+ }
49
+ #${POPOVER_ID}.ag-comment-visible {
50
+ opacity: 1;
51
+ visibility: visible;
52
+ }
53
+ #${POPOVER_ID} textarea {
54
+ width: 100%;
55
+ min-height: 80px;
56
+ padding: 8px 10px;
57
+ border: 1px solid var(--ag-popover-border, #1e293b);
58
+ border-radius: 8px;
59
+ background: var(--ag-surface, #1e293b);
60
+ color: var(--ag-popover-text, #e2e8f0);
61
+ font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
62
+ resize: vertical;
63
+ outline: none;
64
+ box-sizing: border-box;
65
+ }
66
+ #${POPOVER_ID} textarea:focus {
67
+ border-color: var(--ag-accent, #3b82f6);
68
+ }
69
+ #${POPOVER_ID} textarea::placeholder {
70
+ color: var(--ag-text-muted, #64748b);
71
+ }
72
+ #${POPOVER_ID} .ag-comment-footer {
73
+ display: flex;
74
+ justify-content: flex-end;
75
+ gap: 8px;
76
+ margin-top: 10px;
77
+ }
78
+ #${POPOVER_ID} .ag-comment-btn {
79
+ padding: 6px 14px;
80
+ border: none;
81
+ border-radius: 6px;
82
+ font-size: 13px;
83
+ font-weight: 500;
84
+ cursor: pointer;
85
+ transition: background 0.15s ease;
86
+ }
87
+ #${POPOVER_ID} .ag-comment-cancel {
88
+ background: transparent;
89
+ color: var(--ag-text-muted, #64748b);
90
+ }
91
+ #${POPOVER_ID} .ag-comment-cancel:hover {
92
+ background: var(--ag-popover-hover, #1e293b);
93
+ color: var(--ag-popover-text, #e2e8f0);
94
+ }
95
+ #${POPOVER_ID} .ag-comment-submit {
96
+ background: var(--ag-accent, #3b82f6);
97
+ color: #fff;
98
+ }
99
+ #${POPOVER_ID} .ag-comment-submit:hover {
100
+ background: var(--ag-accent-hover, #2563eb);
101
+ }
102
+ `;
103
+ document.head.appendChild(style);
104
+ }
105
+
106
+ function ensurePopover(): HTMLDivElement {
107
+ if (popover) return popover;
108
+
109
+ injectStyles();
110
+
111
+ popover = document.createElement('div');
112
+ popover.id = POPOVER_ID;
113
+ popover.setAttribute('role', 'dialog');
114
+ popover.setAttribute('aria-label', 'Add comment');
115
+
116
+ textarea = document.createElement('textarea');
117
+ textarea.placeholder = 'Add a comment...';
118
+ textarea.rows = 3;
119
+
120
+ const footer = document.createElement('div');
121
+ footer.className = 'ag-comment-footer';
122
+
123
+ const cancelBtn = document.createElement('button');
124
+ cancelBtn.className = 'ag-comment-btn ag-comment-cancel';
125
+ cancelBtn.textContent = 'Cancel';
126
+ cancelBtn.addEventListener('click', (e) => {
127
+ e.preventDefault();
128
+ e.stopPropagation();
129
+ doHide();
130
+ callbacks.onCancel();
131
+ });
132
+
133
+ const submitBtn = document.createElement('button');
134
+ submitBtn.className = 'ag-comment-btn ag-comment-submit';
135
+ submitBtn.textContent = 'Copy with Comment';
136
+ submitBtn.addEventListener('click', (e) => {
137
+ e.preventDefault();
138
+ e.stopPropagation();
139
+ const comment = textarea!.value.trim();
140
+ if (comment) {
141
+ doHide();
142
+ callbacks.onSubmit(comment);
143
+ } else {
144
+ textarea!.style.borderColor = 'var(--ag-accent, #3b82f6)';
145
+ textarea!.setAttribute('placeholder', 'Please enter a comment...');
146
+ textarea!.focus();
147
+ setTimeout(() => {
148
+ if (textarea) {
149
+ textarea.style.borderColor = '';
150
+ textarea.setAttribute('placeholder', 'Add a comment...');
151
+ }
152
+ }, 2000);
153
+ }
154
+ });
155
+
156
+ footer.appendChild(cancelBtn);
157
+ footer.appendChild(submitBtn);
158
+ popover.appendChild(textarea);
159
+ popover.appendChild(footer);
160
+
161
+ document.body.appendChild(popover);
162
+ return popover;
163
+ }
164
+
165
+ function attachKeydownInterceptor(): void {
166
+ if (keydownHandler) return;
167
+
168
+ keydownHandler = (e: KeyboardEvent) => {
169
+ // When textarea is focused, prevent keyboard handler from intercepting
170
+ if (textarea && document.activeElement === textarea) {
171
+ e.stopImmediatePropagation();
172
+
173
+ if (e.key === 'Escape') {
174
+ doHide();
175
+ callbacks.onCancel();
176
+ }
177
+ }
178
+ };
179
+
180
+ document.addEventListener('keydown', keydownHandler, true);
181
+ }
182
+
183
+ function detachKeydownInterceptor(): void {
184
+ if (keydownHandler) {
185
+ document.removeEventListener('keydown', keydownHandler, true);
186
+ keydownHandler = null;
187
+ }
188
+ }
189
+
190
+ function doHide(): void {
191
+ visible = false;
192
+ popover?.classList.remove('ag-comment-visible');
193
+ detachKeydownInterceptor();
194
+ }
195
+
196
+ return {
197
+ show(): void {
198
+ const el = ensurePopover();
199
+ textarea!.value = '';
200
+ visible = true;
201
+ void el.offsetHeight;
202
+ el.classList.add('ag-comment-visible');
203
+ attachKeydownInterceptor();
204
+ // Focus after transition starts
205
+ requestAnimationFrame(() => textarea?.focus());
206
+ },
207
+
208
+ hide(): void {
209
+ doHide();
210
+ },
211
+
212
+ isVisible(): boolean {
213
+ return visible;
214
+ },
215
+
216
+ isPopoverElement(el: Element): boolean {
217
+ if (!popover) return false;
218
+ let current: Element | null = el;
219
+ while (current) {
220
+ if (current === popover || current.id === POPOVER_ID) return true;
221
+ current = current.parentElement;
222
+ }
223
+ return false;
224
+ },
225
+
226
+ dispose(): void {
227
+ detachKeydownInterceptor();
228
+ popover?.remove();
229
+ document.getElementById(STYLE_ID)?.remove();
230
+ popover = null;
231
+ textarea = null;
232
+ visible = false;
233
+ },
234
+ };
235
+ }
@@ -0,0 +1,98 @@
1
+ import type { ElementContext } from '../types';
2
+ import type { PluginRegistry } from '../plugins/plugin-registry';
3
+ import { generateSnippet } from '../clipboard/generate-snippet';
4
+ import { showToast } from '../overlay/toast';
5
+ import { cleanAngularAttrs } from '../utils';
6
+
7
+ export async function copyElementSnippet(
8
+ context: ElementContext,
9
+ maxLines: number,
10
+ pluginRegistry?: PluginRegistry,
11
+ ): Promise<boolean> {
12
+ let snippet = generateSnippet(context, maxLines);
13
+ if (pluginRegistry) {
14
+ snippet = pluginRegistry.callTransformHook(snippet, context);
15
+ }
16
+ const ok = await writeAndToast(snippet, 'Copied to clipboard', context);
17
+ if (ok) pluginRegistry?.callHook('onCopySuccess', snippet, context, undefined);
18
+ return ok;
19
+ }
20
+
21
+ export async function copyElementHtml(
22
+ context: ElementContext,
23
+ pluginRegistry?: PluginRegistry,
24
+ ): Promise<boolean> {
25
+ const cleaned = cleanAngularAttrs(context.html);
26
+ const ok = await writeAndToast(cleaned, 'HTML copied to clipboard', context);
27
+ if (ok) pluginRegistry?.callHook('onCopySuccess', cleaned, context, undefined);
28
+ return ok;
29
+ }
30
+
31
+ export async function copyElementStyles(element: Element): Promise<boolean> {
32
+ if (!element.isConnected) {
33
+ showToast('Element is no longer on the page');
34
+ return false;
35
+ }
36
+
37
+ const computed = window.getComputedStyle(element);
38
+ const tag = element.tagName.toLowerCase();
39
+ const lines: string[] = [`/* Computed styles for <${tag}> */`, `${tag} {`];
40
+
41
+ const props = [
42
+ 'display', 'position', 'top', 'right', 'bottom', 'left',
43
+ 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
44
+ 'margin', 'padding',
45
+ 'border', 'border-radius',
46
+ 'background', 'background-color',
47
+ 'color', 'font', 'font-size', 'font-weight', 'font-family', 'line-height',
48
+ 'text-align', 'text-decoration', 'text-transform',
49
+ 'opacity', 'overflow', 'z-index',
50
+ 'flex-direction', 'justify-content', 'align-items', 'gap',
51
+ 'grid-template-columns', 'grid-template-rows',
52
+ 'box-shadow', 'cursor', 'transition', 'transform',
53
+ ];
54
+
55
+ for (const prop of props) {
56
+ const value = computed.getPropertyValue(prop);
57
+ if (value && value !== 'none' && value !== 'normal' && value !== 'auto' && value !== '0px' && value !== 'visible') {
58
+ lines.push(` ${prop}: ${value};`);
59
+ }
60
+ }
61
+
62
+ lines.push('}');
63
+ const css = lines.join('\n');
64
+
65
+ return writeAndToast(css, 'Styles copied to clipboard');
66
+ }
67
+
68
+ export async function copyWithComment(
69
+ context: ElementContext,
70
+ comment: string,
71
+ maxLines: number,
72
+ pluginRegistry?: PluginRegistry,
73
+ ): Promise<boolean> {
74
+ let snippet = generateSnippet(context, maxLines);
75
+ if (pluginRegistry) {
76
+ snippet = pluginRegistry.callTransformHook(snippet, context);
77
+ }
78
+ const full = `${snippet}\n\n/* Comment: ${comment} */`;
79
+ const ok = await writeAndToast(full, 'Copied with comment', context);
80
+ if (ok) pluginRegistry?.callHook('onCopySuccess', full, context, comment);
81
+ return ok;
82
+ }
83
+
84
+ async function writeAndToast(text: string, message: string, context?: ElementContext): Promise<boolean> {
85
+ try {
86
+ await navigator.clipboard.writeText(text);
87
+ showToast(message, context ? {
88
+ componentName: context.componentName,
89
+ filePath: context.filePath,
90
+ line: context.line,
91
+ column: context.column,
92
+ cssClasses: context.cssClasses,
93
+ } : undefined);
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
@@ -0,0 +1,245 @@
1
+ import type { HistoryEntry } from '../types';
2
+ import { escapeHtml } from '../utils';
3
+ import { Z_INDEX_POPOVER, TOOLBAR_POPOVER_OFFSET } from '../constants';
4
+
5
+ const POPOVER_ID = '__ag-history-popover__';
6
+ const STYLE_ID = '__ag-history-styles__';
7
+
8
+ export interface HistoryPopover {
9
+ show(entries: HistoryEntry[]): void;
10
+ hide(): void;
11
+ isVisible(): boolean;
12
+ isPopoverElement(el: Element): boolean;
13
+ dispose(): void;
14
+ }
15
+
16
+ export interface HistoryPopoverCallbacks {
17
+ onEntryClick: (entry: HistoryEntry) => void;
18
+ }
19
+
20
+ function formatRelativeTime(timestamp: number): string {
21
+ const diff = Date.now() - timestamp;
22
+ const seconds = Math.floor(diff / 1000);
23
+
24
+ if (seconds < 60) return 'just now';
25
+ const minutes = Math.floor(seconds / 60);
26
+ if (minutes < 60) return `${minutes}m ago`;
27
+ const hours = Math.floor(minutes / 60);
28
+ if (hours < 24) return `${hours}h ago`;
29
+ const days = Math.floor(hours / 24);
30
+ return `${days}d ago`;
31
+ }
32
+
33
+ function shortPath(filePath: string): string {
34
+ const parts = filePath.split('/');
35
+ return parts[parts.length - 1];
36
+ }
37
+
38
+ function buildVsCodeUri(filePath: string, line: number | null, column: number | null): string {
39
+ let uri = `vscode://file/${encodeURI(filePath)}`;
40
+ if (line != null) uri += `:${line}`;
41
+ if (line != null && column != null) uri += `:${column}`;
42
+ return uri;
43
+ }
44
+
45
+ export function createHistoryPopover(callbacks: HistoryPopoverCallbacks): HistoryPopover {
46
+ let popover: HTMLDivElement | null = null;
47
+ let visible = false;
48
+
49
+ function injectStyles(): void {
50
+ if (document.getElementById(STYLE_ID)) return;
51
+
52
+ const style = document.createElement('style');
53
+ style.id = STYLE_ID;
54
+ style.textContent = `
55
+ #${POPOVER_ID} {
56
+ position: fixed;
57
+ bottom: ${TOOLBAR_POPOVER_OFFSET};
58
+ left: 50%;
59
+ transform: translateX(-50%);
60
+ z-index: ${Z_INDEX_POPOVER};
61
+ background: var(--ag-popover-bg, #0f172a);
62
+ border: 1px solid var(--ag-popover-border, #1e293b);
63
+ border-radius: 12px;
64
+ box-shadow: 0 8px 24px var(--ag-popover-shadow, rgba(0, 0, 0, 0.5));
65
+ min-width: 320px;
66
+ max-width: 420px;
67
+ max-height: 360px;
68
+ overflow-y: auto;
69
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
70
+ opacity: 0;
71
+ visibility: hidden;
72
+ transition: opacity 0.15s ease, visibility 0.15s ease;
73
+ pointer-events: auto;
74
+ }
75
+ #${POPOVER_ID}.ag-popover-visible {
76
+ opacity: 1;
77
+ visibility: visible;
78
+ }
79
+ #${POPOVER_ID} .ag-history-header {
80
+ padding: 10px 14px 8px;
81
+ font-size: 11px;
82
+ font-weight: 600;
83
+ text-transform: uppercase;
84
+ letter-spacing: 0.05em;
85
+ color: var(--ag-text-muted, #64748b);
86
+ border-bottom: 1px solid var(--ag-popover-border, #1e293b);
87
+ }
88
+ #${POPOVER_ID} .ag-history-empty {
89
+ padding: 24px 14px;
90
+ text-align: center;
91
+ color: var(--ag-text-muted, #64748b);
92
+ font-size: 13px;
93
+ }
94
+ #${POPOVER_ID} .ag-history-item {
95
+ display: flex;
96
+ align-items: center;
97
+ justify-content: space-between;
98
+ gap: 12px;
99
+ padding: 8px 14px;
100
+ cursor: pointer;
101
+ border: none;
102
+ border-bottom: 1px solid var(--ag-popover-border, #1e293b);
103
+ background: transparent;
104
+ width: 100%;
105
+ text-align: left;
106
+ font: inherit;
107
+ color: inherit;
108
+ transition: background 0.1s ease;
109
+ }
110
+ #${POPOVER_ID} .ag-history-item:last-child {
111
+ border-bottom: none;
112
+ }
113
+ #${POPOVER_ID} .ag-history-item:hover {
114
+ background: var(--ag-popover-hover, #1e293b);
115
+ }
116
+ #${POPOVER_ID} .ag-history-info {
117
+ flex: 1;
118
+ min-width: 0;
119
+ }
120
+ #${POPOVER_ID} .ag-history-selector {
121
+ font: 12px/1.3 ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
122
+ color: var(--ag-popover-text, #e2e8f0);
123
+ overflow: hidden;
124
+ text-overflow: ellipsis;
125
+ white-space: nowrap;
126
+ }
127
+ #${POPOVER_ID} .ag-history-meta {
128
+ font-size: 11px;
129
+ color: var(--ag-text-muted, #64748b);
130
+ margin-top: 2px;
131
+ overflow: hidden;
132
+ text-overflow: ellipsis;
133
+ white-space: nowrap;
134
+ }
135
+ #${POPOVER_ID} .ag-history-time {
136
+ font-size: 11px;
137
+ color: var(--ag-text-muted, #64748b);
138
+ flex-shrink: 0;
139
+ }
140
+ #${POPOVER_ID} .ag-history-file-link {
141
+ color: var(--ag-text-muted, #64748b);
142
+ text-decoration: none;
143
+ }
144
+ #${POPOVER_ID} .ag-history-file-link:hover {
145
+ text-decoration: underline;
146
+ color: var(--ag-accent, #3b82f6);
147
+ }
148
+ `;
149
+ document.head.appendChild(style);
150
+ }
151
+
152
+ function ensurePopover(): HTMLDivElement {
153
+ if (popover) return popover;
154
+
155
+ injectStyles();
156
+ popover = document.createElement('div');
157
+ popover.id = POPOVER_ID;
158
+ popover.setAttribute('role', 'dialog');
159
+ popover.setAttribute('aria-label', 'Grab history');
160
+ document.body.appendChild(popover);
161
+ return popover;
162
+ }
163
+
164
+ function render(entries: HistoryEntry[]): void {
165
+ const el = ensurePopover();
166
+
167
+ let html = '<div class="ag-history-header">History</div>';
168
+
169
+ if (entries.length === 0) {
170
+ html += '<div class="ag-history-empty">No elements grabbed yet</div>';
171
+ } else {
172
+ for (const entry of entries) {
173
+ const selector = escapeHtml(entry.context.selector);
174
+ const comp = entry.context.componentName ? escapeHtml(entry.context.componentName) : '';
175
+ const time = formatRelativeTime(entry.timestamp);
176
+ let meta = comp ? `in ${comp}` : '';
177
+ if (entry.context.filePath) {
178
+ const uri = buildVsCodeUri(entry.context.filePath, entry.context.line, entry.context.column);
179
+ const fileName = escapeHtml(shortPath(entry.context.filePath));
180
+ const sep = meta ? ' \u2014 ' : '';
181
+ meta += `${sep}<a class="ag-history-file-link" href="${escapeHtml(uri)}" title="Open in VS Code">${fileName}</a>`;
182
+ }
183
+
184
+ html += `<button class="ag-history-item" data-ag-history-id="${escapeHtml(entry.id)}" aria-label="Re-copy ${selector}">`;
185
+ html += `<div class="ag-history-info">`;
186
+ html += `<div class="ag-history-selector">${selector}</div>`;
187
+ if (meta) html += `<div class="ag-history-meta">${meta}</div>`;
188
+ html += `</div>`;
189
+ html += `<span class="ag-history-time">${time}</span>`;
190
+ html += `</button>`;
191
+ }
192
+ }
193
+
194
+ el.innerHTML = html;
195
+
196
+ // Attach click handlers
197
+ const items = el.querySelectorAll('.ag-history-item');
198
+ items.forEach((item) => {
199
+ item.addEventListener('click', (e) => {
200
+ e.stopPropagation();
201
+ const id = (item as HTMLElement).dataset.agHistoryId;
202
+ const entry = entries.find((ent) => ent.id === id);
203
+ if (entry) {
204
+ callbacks.onEntryClick(entry);
205
+ }
206
+ });
207
+ });
208
+ }
209
+
210
+ return {
211
+ show(entries: HistoryEntry[]): void {
212
+ render(entries);
213
+ visible = true;
214
+ // Force reflow for transition
215
+ void ensurePopover().offsetHeight;
216
+ ensurePopover().classList.add('ag-popover-visible');
217
+ },
218
+
219
+ hide(): void {
220
+ visible = false;
221
+ popover?.classList.remove('ag-popover-visible');
222
+ },
223
+
224
+ isVisible(): boolean {
225
+ return visible;
226
+ },
227
+
228
+ isPopoverElement(el: Element): boolean {
229
+ if (!popover) return false;
230
+ let current: Element | null = el;
231
+ while (current) {
232
+ if (current === popover || current.id === POPOVER_ID) return true;
233
+ current = current.parentElement;
234
+ }
235
+ return false;
236
+ },
237
+
238
+ dispose(): void {
239
+ popover?.remove();
240
+ document.getElementById(STYLE_ID)?.remove();
241
+ popover = null;
242
+ visible = false;
243
+ },
244
+ };
245
+ }