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,108 @@
1
+ import { Z_INDEX_OVERLAY, Z_INDEX_LABEL } from '../constants';
2
+
3
+ const STYLE_ID = '__ag-feedback-styles__';
4
+
5
+ function injectStyles(): void {
6
+ if (document.getElementById(STYLE_ID)) return;
7
+
8
+ const style = document.createElement('style');
9
+ style.id = STYLE_ID;
10
+ style.textContent = `
11
+ @keyframes ag-flash {
12
+ 0% { opacity: 1; }
13
+ 100% { opacity: 0; transform: scale(1.02); }
14
+ }
15
+ @keyframes ag-pill-in {
16
+ 0% { opacity: 0; transform: translateY(4px) scale(0.9); }
17
+ 30% { opacity: 1; transform: translateY(0) scale(1); }
18
+ 70% { opacity: 1; transform: translateY(0) scale(1); }
19
+ 100% { opacity: 0; transform: translateY(-8px) scale(0.95); }
20
+ }
21
+ .ag-select-flash {
22
+ position: fixed;
23
+ pointer-events: none;
24
+ z-index: ${Z_INDEX_OVERLAY};
25
+ border: 2px solid #22c55e;
26
+ background: rgba(34, 197, 94, 0.12);
27
+ border-radius: 3px;
28
+ box-sizing: border-box;
29
+ animation: ag-flash 0.45s ease-out forwards;
30
+ }
31
+ .ag-select-pill {
32
+ position: fixed;
33
+ pointer-events: none;
34
+ z-index: ${Z_INDEX_LABEL};
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 4px;
38
+ background: #22c55e;
39
+ color: #fff;
40
+ font: 600 10px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
41
+ padding: 3px 8px;
42
+ border-radius: 10px;
43
+ white-space: nowrap;
44
+ letter-spacing: 0.03em;
45
+ text-transform: uppercase;
46
+ box-shadow: 0 2px 8px rgba(34, 197, 94, 0.35);
47
+ animation: ag-pill-in 0.9s ease-out forwards;
48
+ }
49
+ .ag-select-pill svg {
50
+ width: 10px;
51
+ height: 10px;
52
+ flex-shrink: 0;
53
+ }
54
+ `;
55
+ document.head.appendChild(style);
56
+ }
57
+
58
+ const CHECK_SVG = `<svg viewBox="0 0 10 10" fill="none"><path d="M2 5.5l2 2 4-4" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
59
+
60
+ export function showSelectFeedback(element: Element): void {
61
+ injectStyles();
62
+
63
+ const rect = element.getBoundingClientRect();
64
+
65
+ // Green flash overlay on the element
66
+ const flash = document.createElement('div');
67
+ flash.className = 'ag-select-flash';
68
+ flash.style.top = `${rect.top}px`;
69
+ flash.style.left = `${rect.left}px`;
70
+ flash.style.width = `${rect.width}px`;
71
+ flash.style.height = `${rect.height}px`;
72
+ document.body.appendChild(flash);
73
+
74
+ // "Copied" pill above the element
75
+ const pill = document.createElement('div');
76
+ pill.className = 'ag-select-pill';
77
+ pill.innerHTML = `${CHECK_SVG} Copied`;
78
+ document.body.appendChild(pill);
79
+
80
+ // Position pill centered above the element (or below if no room)
81
+ const pillWidth = 70; // approximate
82
+ let pillLeft = rect.left + rect.width / 2 - pillWidth / 2;
83
+ let pillTop = rect.top - 24;
84
+ if (pillTop < 4) {
85
+ pillTop = rect.bottom + 6;
86
+ }
87
+ // Clamp to viewport
88
+ const vw = document.documentElement.clientWidth;
89
+ if (pillLeft + pillWidth > vw - 4) pillLeft = vw - pillWidth - 4;
90
+ if (pillLeft < 4) pillLeft = 4;
91
+
92
+ pill.style.top = `${pillTop}px`;
93
+ pill.style.left = `${pillLeft}px`;
94
+
95
+ // Clean up after animations complete
96
+ const cleanup = () => {
97
+ flash.remove();
98
+ pill.remove();
99
+ };
100
+ pill.addEventListener('animationend', cleanup);
101
+
102
+ // Safety fallback
103
+ setTimeout(cleanup, 1200);
104
+ }
105
+
106
+ export function disposeFeedbackStyles(): void {
107
+ document.getElementById(STYLE_ID)?.remove();
108
+ }
@@ -0,0 +1,175 @@
1
+ import { escapeHtml } from '../utils';
2
+ import { Z_INDEX_TOAST } from '../constants';
3
+
4
+ const TOAST_ID = '__ag-toast__';
5
+ const TOAST_STYLE_ID = '__ag-toast-styles__';
6
+
7
+ // Note: toast state is shared across instances (module-level singleton)
8
+ let activeTimer: ReturnType<typeof setTimeout> | null = null;
9
+
10
+ export interface ToastDetail {
11
+ componentName: string | null;
12
+ filePath: string | null;
13
+ line: number | null;
14
+ column: number | null;
15
+ cssClasses?: string[];
16
+ }
17
+
18
+ function injectToastStyles(): void {
19
+ if (document.getElementById(TOAST_STYLE_ID)) return;
20
+
21
+ const style = document.createElement('style');
22
+ style.id = TOAST_STYLE_ID;
23
+ style.textContent = `
24
+ #${TOAST_ID} {
25
+ position: fixed;
26
+ bottom: var(--ag-toast-bottom, 24px);
27
+ left: 50%;
28
+ transform: translateX(-50%) translateY(100%);
29
+ z-index: ${Z_INDEX_TOAST};
30
+ background: var(--ag-toast-bg, #0f172a);
31
+ color: var(--ag-toast-text, #e2e8f0);
32
+ font: 500 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
33
+ padding: 12px 18px;
34
+ border-radius: 10px;
35
+ box-shadow: 0 8px 24px var(--ag-toast-shadow, rgba(0, 0, 0, 0.4));
36
+ pointer-events: none;
37
+ opacity: 0;
38
+ transition: transform 0.25s ease, opacity 0.25s ease;
39
+ letter-spacing: 0.01em;
40
+ max-width: 480px;
41
+ min-width: 260px;
42
+ }
43
+ #${TOAST_ID}.ag-toast-visible {
44
+ transform: translateX(-50%) translateY(0);
45
+ opacity: 1;
46
+ }
47
+ #${TOAST_ID} .ag-toast-header {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 8px;
51
+ margin-bottom: 0;
52
+ }
53
+ #${TOAST_ID} .ag-toast-icon {
54
+ flex-shrink: 0;
55
+ width: 16px;
56
+ height: 16px;
57
+ }
58
+ #${TOAST_ID} .ag-toast-title {
59
+ font-weight: 600;
60
+ color: var(--ag-toast-title, #fff);
61
+ }
62
+ #${TOAST_ID} .ag-toast-details {
63
+ margin-top: 8px;
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: 4px;
67
+ font-size: 12px;
68
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
69
+ }
70
+ #${TOAST_ID} .ag-toast-row {
71
+ display: flex;
72
+ gap: 8px;
73
+ align-items: baseline;
74
+ }
75
+ #${TOAST_ID} .ag-toast-label {
76
+ color: var(--ag-toast-label, #64748b);
77
+ flex-shrink: 0;
78
+ min-width: 72px;
79
+ }
80
+ #${TOAST_ID} .ag-toast-value {
81
+ color: var(--ag-toast-text, #e2e8f0);
82
+ overflow: hidden;
83
+ text-overflow: ellipsis;
84
+ white-space: nowrap;
85
+ }
86
+ #${TOAST_ID} .ag-toast-file-link {
87
+ color: var(--ag-toast-text, #e2e8f0);
88
+ text-decoration: none;
89
+ overflow: hidden;
90
+ text-overflow: ellipsis;
91
+ white-space: nowrap;
92
+ pointer-events: auto;
93
+ cursor: pointer;
94
+ }
95
+ #${TOAST_ID} .ag-toast-file-link:hover {
96
+ text-decoration: underline;
97
+ color: var(--ag-accent, #3b82f6);
98
+ }
99
+ `;
100
+ document.head.appendChild(style);
101
+ }
102
+
103
+ function getOrCreateToast(): HTMLDivElement {
104
+ let toast = document.getElementById(TOAST_ID) as HTMLDivElement | null;
105
+ if (!toast) {
106
+ injectToastStyles();
107
+ toast = document.createElement('div');
108
+ toast.id = TOAST_ID;
109
+ document.body.appendChild(toast);
110
+ }
111
+ return toast;
112
+ }
113
+
114
+ const CHECKMARK_SVG = `<svg class="ag-toast-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="7" fill="#22c55e"/><path d="M5 8l2 2 4-4" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
115
+
116
+ export function showToast(message: string, detail?: ToastDetail, durationMs = 3500): void {
117
+ const toast = getOrCreateToast();
118
+
119
+ let html = `<div class="ag-toast-header">${CHECKMARK_SVG}<span class="ag-toast-title">${escapeHtml(message)}</span></div>`;
120
+
121
+ if (detail) {
122
+ html += '<div class="ag-toast-details">';
123
+
124
+ if (detail.componentName) {
125
+ html += `<div class="ag-toast-row"><span class="ag-toast-label">Component</span><span class="ag-toast-value">${escapeHtml(detail.componentName)}</span></div>`;
126
+ }
127
+
128
+ if (detail.filePath) {
129
+ let loc = detail.filePath;
130
+ if (detail.line != null) loc += `:${detail.line}`;
131
+
132
+ let vsCodeUri = `vscode://file/${encodeURI(detail.filePath)}`;
133
+ if (detail.line != null) vsCodeUri += `:${detail.line}`;
134
+ if (detail.line != null && detail.column != null) vsCodeUri += `:${detail.column}`;
135
+
136
+ html += `<div class="ag-toast-row"><span class="ag-toast-label">File</span>`;
137
+ html += `<a class="ag-toast-file-link" href="${escapeHtml(vsCodeUri)}" title="Open in VS Code">${escapeHtml(loc)}</a>`;
138
+ html += `</div>`;
139
+ }
140
+
141
+ if (detail.cssClasses && detail.cssClasses.length > 0) {
142
+ const classes = detail.cssClasses.map((c) => `.${escapeHtml(c)}`).join(' ');
143
+ html += `<div class="ag-toast-row"><span class="ag-toast-label">Classes</span><span class="ag-toast-value">${classes}</span></div>`;
144
+ }
145
+
146
+ html += '</div>';
147
+ }
148
+
149
+ toast.innerHTML = html;
150
+
151
+ if (activeTimer) {
152
+ clearTimeout(activeTimer);
153
+ activeTimer = null;
154
+ }
155
+
156
+ // Force reflow to restart animation if already visible
157
+ toast.classList.remove('ag-toast-visible');
158
+ void toast.offsetHeight;
159
+
160
+ toast.classList.add('ag-toast-visible');
161
+
162
+ activeTimer = setTimeout(() => {
163
+ toast.classList.remove('ag-toast-visible');
164
+ activeTimer = null;
165
+ }, durationMs);
166
+ }
167
+
168
+ export function disposeToast(): void {
169
+ if (activeTimer) {
170
+ clearTimeout(activeTimer);
171
+ activeTimer = null;
172
+ }
173
+ document.getElementById(TOAST_ID)?.remove();
174
+ document.getElementById(TOAST_STYLE_ID)?.remove();
175
+ }
@@ -0,0 +1,114 @@
1
+ import type { OverlayRenderer } from '../overlay/overlay-renderer';
2
+ import type { Crosshair } from '../overlay/crosshair';
3
+ import type { ComponentResolver, SourceResolver } from '../types';
4
+ import { filterAngularClasses } from '../utils';
5
+
6
+ export interface ElementPicker {
7
+ activate(): void;
8
+ deactivate(): void;
9
+ getHoveredElement(): Element | null;
10
+ dispose(): void;
11
+ }
12
+
13
+ export interface ElementPickerDeps {
14
+ overlay: OverlayRenderer;
15
+ crosshair: Crosshair;
16
+ getComponentResolver: () => ComponentResolver | null;
17
+ getSourceResolver: () => SourceResolver | null;
18
+ isToolbarElement?: (el: Element) => boolean;
19
+ getFreezeElement?: () => HTMLElement | null;
20
+ onHover: (element: Element | null) => void;
21
+ onSelect: (element: Element) => void;
22
+ }
23
+
24
+ export function createElementPicker(deps: ElementPickerDeps): ElementPicker {
25
+ let hoveredElement: Element | null = null;
26
+ let listening = false;
27
+
28
+ function resolveComponentName(el: Element): string | null {
29
+ const resolver = deps.getComponentResolver();
30
+ if (!resolver) return null;
31
+
32
+ const result = resolver(el);
33
+ return result?.name ?? null;
34
+ }
35
+
36
+ function resolveSourcePath(el: Element): string | null {
37
+ const resolver = deps.getSourceResolver();
38
+ if (!resolver) return null;
39
+
40
+ const result = resolver(el);
41
+ if (!result?.filePath) return null;
42
+
43
+ let path = result.filePath;
44
+ if (result.line != null) {
45
+ path += `:${result.line}`;
46
+ }
47
+ return path;
48
+ }
49
+
50
+ function elementAtPoint(x: number, y: number): Element | null {
51
+ // Temporarily hide freeze overlay so elementFromPoint can see through it
52
+ const freezeEl = deps.getFreezeElement?.();
53
+ if (freezeEl) freezeEl.style.pointerEvents = 'none';
54
+ const target = document.elementFromPoint(x, y);
55
+ if (freezeEl) freezeEl.style.pointerEvents = 'auto';
56
+ return target;
57
+ }
58
+
59
+ function handleMouseMove(e: MouseEvent): void {
60
+ const target = elementAtPoint(e.clientX, e.clientY);
61
+ if (!target || deps.overlay.isOverlayElement(target)) return;
62
+ if (deps.crosshair.isCrosshairElement(target)) return;
63
+ if (deps.isToolbarElement?.(target)) return;
64
+ if (target === hoveredElement) return;
65
+
66
+ hoveredElement = target;
67
+ const componentName = resolveComponentName(target);
68
+ const sourcePath = resolveSourcePath(target);
69
+ const cssClasses = filterAngularClasses(target.classList);
70
+ deps.overlay.show(target, componentName, sourcePath, cssClasses);
71
+ deps.onHover(target);
72
+ }
73
+
74
+ function handleClick(e: MouseEvent): void {
75
+ const target = elementAtPoint(e.clientX, e.clientY);
76
+ if (target && (deps.isToolbarElement?.(target) || deps.crosshair.isCrosshairElement(target))) return;
77
+
78
+ e.preventDefault();
79
+ e.stopPropagation();
80
+
81
+ if (hoveredElement) {
82
+ deps.onSelect(hoveredElement);
83
+ }
84
+ }
85
+
86
+ return {
87
+ activate(): void {
88
+ if (listening) return;
89
+ listening = true;
90
+ deps.crosshair.activate();
91
+ document.addEventListener('mousemove', handleMouseMove, true);
92
+ document.addEventListener('click', handleClick, true);
93
+ },
94
+
95
+ deactivate(): void {
96
+ if (!listening) return;
97
+ listening = false;
98
+ hoveredElement = null;
99
+ deps.crosshair.deactivate();
100
+ document.removeEventListener('mousemove', handleMouseMove, true);
101
+ document.removeEventListener('click', handleClick, true);
102
+ deps.overlay.hide();
103
+ deps.onHover(null);
104
+ },
105
+
106
+ getHoveredElement(): Element | null {
107
+ return hoveredElement;
108
+ },
109
+
110
+ dispose(): void {
111
+ this.deactivate();
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,83 @@
1
+ import type { Plugin, PluginHooks, PluginCleanup, ElementContext, AngularGrabAPI } from '../types';
2
+
3
+ export interface PluginRegistry {
4
+ register(plugin: Plugin, api: AngularGrabAPI): void;
5
+ unregister(name: string): void;
6
+ callHook<K extends keyof PluginHooks>(hookName: K, ...args: Parameters<NonNullable<PluginHooks[K]>>): void;
7
+ callTransformHook(text: string, context: ElementContext): string;
8
+ getPlugins(): ReadonlyArray<Plugin>;
9
+ dispose(): void;
10
+ }
11
+
12
+ export function createPluginRegistry(): PluginRegistry {
13
+ const plugins = new Map<string, Plugin>();
14
+ const cleanups = new Map<string, PluginCleanup>();
15
+
16
+ return {
17
+ register(plugin: Plugin, api: AngularGrabAPI): void {
18
+ if (plugins.has(plugin.name)) {
19
+ this.unregister(plugin.name);
20
+ }
21
+ plugins.set(plugin.name, plugin);
22
+
23
+ if (plugin.setup) {
24
+ const cleanup = plugin.setup(api);
25
+ if (cleanup) {
26
+ cleanups.set(plugin.name, cleanup);
27
+ }
28
+ }
29
+ },
30
+
31
+ unregister(name: string): void {
32
+ const cleanup = cleanups.get(name);
33
+ if (cleanup) {
34
+ cleanup();
35
+ cleanups.delete(name);
36
+ }
37
+ plugins.delete(name);
38
+ },
39
+
40
+ callHook<K extends keyof PluginHooks>(hookName: K, ...args: Parameters<NonNullable<PluginHooks[K]>>): void {
41
+ for (const plugin of plugins.values()) {
42
+ const hook = plugin.hooks?.[hookName];
43
+ if (hook) {
44
+ try {
45
+ (hook as (...a: any[]) => void)(...args);
46
+ } catch (err) {
47
+ console.warn(`[angular-grab] Plugin "${plugin.name}" hook "${hookName}" threw:`, err);
48
+ }
49
+ }
50
+ }
51
+ },
52
+
53
+ callTransformHook(text: string, context: ElementContext): string {
54
+ let result = text;
55
+ for (const plugin of plugins.values()) {
56
+ const transform = plugin.hooks?.transformCopyContent;
57
+ if (transform) {
58
+ try {
59
+ result = transform(result, context);
60
+ } catch (err) {
61
+ console.warn(`[angular-grab] Plugin "${plugin.name}" transformCopyContent threw:`, err);
62
+ }
63
+ }
64
+ }
65
+ return result;
66
+ },
67
+
68
+ getPlugins(): ReadonlyArray<Plugin> {
69
+ return Array.from(plugins.values());
70
+ },
71
+
72
+ dispose(): void {
73
+ for (const [name] of plugins) {
74
+ const cleanup = cleanups.get(name);
75
+ if (cleanup) {
76
+ try { cleanup(); } catch { /* ignore */ }
77
+ }
78
+ }
79
+ cleanups.clear();
80
+ plugins.clear();
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,52 @@
1
+ import type { AngularGrabOptions, ToolbarState } from './types';
2
+
3
+ export interface GrabState {
4
+ active: boolean;
5
+ frozen: boolean;
6
+ hoveredElement: Element | null;
7
+ options: AngularGrabOptions;
8
+ toolbar: ToolbarState;
9
+ }
10
+
11
+ export type StateListener = (state: GrabState, key: keyof GrabState) => void;
12
+
13
+ export interface Store {
14
+ state: GrabState;
15
+ subscribe(listener: StateListener): () => void;
16
+ }
17
+
18
+ export function createStore(initialOptions: AngularGrabOptions): Store {
19
+ const listeners = new Set<StateListener>();
20
+
21
+ const raw: GrabState = {
22
+ active: false,
23
+ frozen: false,
24
+ hoveredElement: null,
25
+ options: initialOptions,
26
+ toolbar: {
27
+ visible: initialOptions.showToolbar,
28
+ themeMode: initialOptions.themeMode,
29
+ history: [],
30
+ pendingAction: null,
31
+ },
32
+ };
33
+
34
+ const state = new Proxy(raw, {
35
+ set(target, prop, value) {
36
+ const key = prop as keyof GrabState;
37
+ if (target[key] === value) return true;
38
+
39
+ Reflect.set(target, key, value);
40
+ listeners.forEach((fn) => fn(state, key));
41
+ return true;
42
+ },
43
+ });
44
+
45
+ return {
46
+ state,
47
+ subscribe(listener: StateListener) {
48
+ listeners.add(listener);
49
+ return () => listeners.delete(listener);
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,178 @@
1
+ import { ICON_COPY, ICON_STYLES, ICON_CODE, ICON_COMMENT, ICON_TRASH } from './toolbar-icons';
2
+ import { escapeHtml } from '../utils';
3
+ import { Z_INDEX_POPOVER, TOOLBAR_POPOVER_OFFSET } from '../constants';
4
+
5
+ const MENU_ID = '__ag-actions-menu__';
6
+ const STYLE_ID = '__ag-actions-styles__';
7
+
8
+ export interface ActionsMenuCallbacks {
9
+ onCopyElement: () => void;
10
+ onCopyStyles: () => void;
11
+ onCopyHtml: () => void;
12
+ onComment: () => void;
13
+ onClearHistory: () => void;
14
+ }
15
+
16
+ export interface ActionsMenu {
17
+ show(): void;
18
+ hide(): void;
19
+ isVisible(): boolean;
20
+ isMenuElement(el: Element): boolean;
21
+ dispose(): void;
22
+ }
23
+
24
+ interface MenuItem {
25
+ icon: string;
26
+ label: string;
27
+ action: () => void;
28
+ separator?: false;
29
+ }
30
+
31
+ interface MenuSeparator {
32
+ separator: true;
33
+ }
34
+
35
+ type MenuEntry = MenuItem | MenuSeparator;
36
+
37
+ export function createActionsMenu(callbacks: ActionsMenuCallbacks): ActionsMenu {
38
+ let menu: HTMLDivElement | null = null;
39
+ let visible = false;
40
+
41
+ const items: MenuEntry[] = [
42
+ { icon: ICON_COPY, label: 'Copy Element', action: callbacks.onCopyElement },
43
+ { icon: ICON_STYLES, label: 'Copy Styles', action: callbacks.onCopyStyles },
44
+ { icon: ICON_CODE, label: 'Copy HTML', action: callbacks.onCopyHtml },
45
+ { icon: ICON_COMMENT, label: 'Comment', action: callbacks.onComment },
46
+ { separator: true },
47
+ { icon: ICON_TRASH, label: 'Clear History', action: callbacks.onClearHistory },
48
+ ];
49
+
50
+ function injectStyles(): void {
51
+ if (document.getElementById(STYLE_ID)) return;
52
+
53
+ const style = document.createElement('style');
54
+ style.id = STYLE_ID;
55
+ style.textContent = `
56
+ #${MENU_ID} {
57
+ position: fixed;
58
+ bottom: ${TOOLBAR_POPOVER_OFFSET};
59
+ left: 50%;
60
+ transform: translateX(-50%);
61
+ z-index: ${Z_INDEX_POPOVER};
62
+ background: var(--ag-popover-bg, #0f172a);
63
+ border: 1px solid var(--ag-popover-border, #1e293b);
64
+ border-radius: 10px;
65
+ box-shadow: 0 8px 24px var(--ag-popover-shadow, rgba(0, 0, 0, 0.5));
66
+ min-width: 200px;
67
+ padding: 4px;
68
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
69
+ opacity: 0;
70
+ visibility: hidden;
71
+ transition: opacity 0.15s ease, visibility 0.15s ease;
72
+ pointer-events: auto;
73
+ }
74
+ #${MENU_ID}.ag-menu-visible {
75
+ opacity: 1;
76
+ visibility: visible;
77
+ }
78
+ #${MENU_ID} .ag-menu-item {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 10px;
82
+ padding: 8px 12px;
83
+ border: none;
84
+ border-radius: 6px;
85
+ background: transparent;
86
+ color: var(--ag-popover-text, #e2e8f0);
87
+ font-size: 13px;
88
+ cursor: pointer;
89
+ width: 100%;
90
+ text-align: left;
91
+ transition: background 0.1s ease;
92
+ }
93
+ #${MENU_ID} .ag-menu-item:hover {
94
+ background: var(--ag-popover-hover, #1e293b);
95
+ }
96
+ #${MENU_ID} .ag-menu-item svg {
97
+ flex-shrink: 0;
98
+ opacity: 0.7;
99
+ }
100
+ #${MENU_ID} .ag-menu-sep {
101
+ height: 1px;
102
+ background: var(--ag-popover-border, #1e293b);
103
+ margin: 4px 8px;
104
+ }
105
+ `;
106
+ document.head.appendChild(style);
107
+ }
108
+
109
+ function ensureMenu(): HTMLDivElement {
110
+ if (menu) return menu;
111
+
112
+ injectStyles();
113
+ menu = document.createElement('div');
114
+ menu.id = MENU_ID;
115
+ menu.setAttribute('role', 'menu');
116
+ menu.setAttribute('aria-label', 'Actions');
117
+
118
+ for (const entry of items) {
119
+ if (entry.separator) {
120
+ const sep = document.createElement('div');
121
+ sep.className = 'ag-menu-sep';
122
+ menu.appendChild(sep);
123
+ continue;
124
+ }
125
+
126
+ const btn = document.createElement('button');
127
+ btn.className = 'ag-menu-item';
128
+ btn.setAttribute('role', 'menuitem');
129
+ btn.innerHTML = `${entry.icon}<span>${escapeHtml(entry.label)}</span>`;
130
+ btn.addEventListener('click', (e) => {
131
+ e.preventDefault();
132
+ e.stopPropagation();
133
+ visible = false;
134
+ menu?.classList.remove('ag-menu-visible');
135
+ entry.action();
136
+ });
137
+ menu.appendChild(btn);
138
+ }
139
+
140
+ document.body.appendChild(menu);
141
+ return menu;
142
+ }
143
+
144
+ return {
145
+ show(): void {
146
+ const el = ensureMenu();
147
+ visible = true;
148
+ void el.offsetHeight;
149
+ el.classList.add('ag-menu-visible');
150
+ },
151
+
152
+ hide(): void {
153
+ visible = false;
154
+ menu?.classList.remove('ag-menu-visible');
155
+ },
156
+
157
+ isVisible(): boolean {
158
+ return visible;
159
+ },
160
+
161
+ isMenuElement(el: Element): boolean {
162
+ if (!menu) return false;
163
+ let current: Element | null = el;
164
+ while (current) {
165
+ if (current === menu || current.id === MENU_ID) return true;
166
+ current = current.parentElement;
167
+ }
168
+ return false;
169
+ },
170
+
171
+ dispose(): void {
172
+ menu?.remove();
173
+ document.getElementById(STYLE_ID)?.remove();
174
+ menu = null;
175
+ visible = false;
176
+ },
177
+ };
178
+ }