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,286 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createPluginRegistry } from '../plugins/plugin-registry';
3
+ import type { Plugin, AngularGrabAPI, ElementContext } from '../types';
4
+
5
+ function makeMockApi(): AngularGrabAPI {
6
+ return {
7
+ activate: vi.fn(),
8
+ deactivate: vi.fn(),
9
+ toggle: vi.fn(),
10
+ isActive: vi.fn(() => false),
11
+ setOptions: vi.fn(),
12
+ registerPlugin: vi.fn(),
13
+ unregisterPlugin: vi.fn(),
14
+ setComponentResolver: vi.fn(),
15
+ setSourceResolver: vi.fn(),
16
+ showToolbar: vi.fn(),
17
+ hideToolbar: vi.fn(),
18
+ setThemeMode: vi.fn(),
19
+ getHistory: vi.fn(() => []),
20
+ clearHistory: vi.fn(),
21
+ dispose: vi.fn(),
22
+ };
23
+ }
24
+
25
+ function makeContext(overrides: Partial<ElementContext> = {}): ElementContext {
26
+ return {
27
+ element: null as any,
28
+ html: '<div>test</div>',
29
+ componentName: null,
30
+ filePath: null,
31
+ line: null,
32
+ column: null,
33
+ componentStack: [],
34
+ selector: 'div',
35
+ cssClasses: [],
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ describe('createPluginRegistry', () => {
41
+ it('registers a plugin', () => {
42
+ const registry = createPluginRegistry();
43
+ const api = makeMockApi();
44
+ const plugin: Plugin = { name: 'test-plugin' };
45
+
46
+ registry.register(plugin, api);
47
+
48
+ expect(registry.getPlugins()).toHaveLength(1);
49
+ expect(registry.getPlugins()[0].name).toBe('test-plugin');
50
+ });
51
+
52
+ it('calls setup on register and stores cleanup', () => {
53
+ const registry = createPluginRegistry();
54
+ const api = makeMockApi();
55
+ const cleanup = vi.fn();
56
+ const plugin: Plugin = {
57
+ name: 'with-setup',
58
+ setup: vi.fn(() => cleanup),
59
+ };
60
+
61
+ registry.register(plugin, api);
62
+
63
+ expect(plugin.setup).toHaveBeenCalledWith(api);
64
+ });
65
+
66
+ it('unregisters a plugin', () => {
67
+ const registry = createPluginRegistry();
68
+ const api = makeMockApi();
69
+ const plugin: Plugin = { name: 'removable' };
70
+
71
+ registry.register(plugin, api);
72
+ expect(registry.getPlugins()).toHaveLength(1);
73
+
74
+ registry.unregister('removable');
75
+ expect(registry.getPlugins()).toHaveLength(0);
76
+ });
77
+
78
+ it('calls cleanup on unregister', () => {
79
+ const registry = createPluginRegistry();
80
+ const api = makeMockApi();
81
+ const cleanup = vi.fn();
82
+ const plugin: Plugin = {
83
+ name: 'cleanup-test',
84
+ setup: () => cleanup,
85
+ };
86
+
87
+ registry.register(plugin, api);
88
+ registry.unregister('cleanup-test');
89
+
90
+ expect(cleanup).toHaveBeenCalledTimes(1);
91
+ });
92
+
93
+ it('re-registers a plugin by unregistering the old one first', () => {
94
+ const registry = createPluginRegistry();
95
+ const api = makeMockApi();
96
+ const cleanup = vi.fn();
97
+ const plugin1: Plugin = {
98
+ name: 'dup',
99
+ setup: () => cleanup,
100
+ };
101
+ const plugin2: Plugin = { name: 'dup' };
102
+
103
+ registry.register(plugin1, api);
104
+ registry.register(plugin2, api);
105
+
106
+ expect(cleanup).toHaveBeenCalledTimes(1);
107
+ expect(registry.getPlugins()).toHaveLength(1);
108
+ });
109
+
110
+ it('unregister is a no-op for unknown plugins', () => {
111
+ const registry = createPluginRegistry();
112
+ // Should not throw
113
+ registry.unregister('nonexistent');
114
+ expect(registry.getPlugins()).toHaveLength(0);
115
+ });
116
+
117
+ describe('callHook', () => {
118
+ it('calls registered plugin hooks', () => {
119
+ const registry = createPluginRegistry();
120
+ const api = makeMockApi();
121
+ const onActivate = vi.fn();
122
+ const plugin: Plugin = {
123
+ name: 'hook-test',
124
+ hooks: { onActivate },
125
+ };
126
+
127
+ registry.register(plugin, api);
128
+ registry.callHook('onActivate');
129
+
130
+ expect(onActivate).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it('calls hooks on all registered plugins', () => {
134
+ const registry = createPluginRegistry();
135
+ const api = makeMockApi();
136
+ const onActivate1 = vi.fn();
137
+ const onActivate2 = vi.fn();
138
+
139
+ registry.register({ name: 'p1', hooks: { onActivate: onActivate1 } }, api);
140
+ registry.register({ name: 'p2', hooks: { onActivate: onActivate2 } }, api);
141
+ registry.callHook('onActivate');
142
+
143
+ expect(onActivate1).toHaveBeenCalledTimes(1);
144
+ expect(onActivate2).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it('passes arguments to hooks', () => {
148
+ const registry = createPluginRegistry();
149
+ const api = makeMockApi();
150
+ const onElementSelect = vi.fn();
151
+ const ctx = makeContext();
152
+
153
+ registry.register({ name: 'args-test', hooks: { onElementSelect } }, api);
154
+ registry.callHook('onElementSelect', ctx);
155
+
156
+ expect(onElementSelect).toHaveBeenCalledWith(ctx);
157
+ });
158
+
159
+ it('handles plugins with no hooks gracefully', () => {
160
+ const registry = createPluginRegistry();
161
+ const api = makeMockApi();
162
+
163
+ registry.register({ name: 'no-hooks' }, api);
164
+
165
+ // Should not throw
166
+ expect(() => registry.callHook('onActivate')).not.toThrow();
167
+ });
168
+
169
+ it('handles plugins without the specific hook', () => {
170
+ const registry = createPluginRegistry();
171
+ const api = makeMockApi();
172
+
173
+ registry.register(
174
+ { name: 'partial', hooks: { onDeactivate: vi.fn() } },
175
+ api,
176
+ );
177
+
178
+ // Should not throw when calling a hook the plugin doesn't have
179
+ expect(() => registry.callHook('onActivate')).not.toThrow();
180
+ });
181
+
182
+ it('catches and warns on hook errors without stopping other plugins', () => {
183
+ const registry = createPluginRegistry();
184
+ const api = makeMockApi();
185
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
186
+ const onActivate2 = vi.fn();
187
+
188
+ registry.register({
189
+ name: 'bad',
190
+ hooks: { onActivate: () => { throw new Error('boom'); } },
191
+ }, api);
192
+ registry.register({ name: 'good', hooks: { onActivate: onActivate2 } }, api);
193
+
194
+ registry.callHook('onActivate');
195
+
196
+ expect(warnSpy).toHaveBeenCalledTimes(1);
197
+ expect(onActivate2).toHaveBeenCalledTimes(1);
198
+ warnSpy.mockRestore();
199
+ });
200
+ });
201
+
202
+ describe('callTransformHook', () => {
203
+ it('chains transforms across plugins', () => {
204
+ const registry = createPluginRegistry();
205
+ const api = makeMockApi();
206
+ const ctx = makeContext();
207
+
208
+ registry.register({
209
+ name: 'upper',
210
+ hooks: { transformCopyContent: (text: string) => text.toUpperCase() },
211
+ }, api);
212
+ registry.register({
213
+ name: 'prefix',
214
+ hooks: { transformCopyContent: (text: string) => `PREFIX:${text}` },
215
+ }, api);
216
+
217
+ const result = registry.callTransformHook('hello', ctx);
218
+ expect(result).toBe('PREFIX:HELLO');
219
+ });
220
+
221
+ it('returns original text when no plugins have transform', () => {
222
+ const registry = createPluginRegistry();
223
+ const api = makeMockApi();
224
+ const ctx = makeContext();
225
+
226
+ registry.register({ name: 'no-transform' }, api);
227
+
228
+ const result = registry.callTransformHook('original', ctx);
229
+ expect(result).toBe('original');
230
+ });
231
+
232
+ it('catches transform errors and continues', () => {
233
+ const registry = createPluginRegistry();
234
+ const api = makeMockApi();
235
+ const ctx = makeContext();
236
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
237
+
238
+ registry.register({
239
+ name: 'bad-transform',
240
+ hooks: { transformCopyContent: () => { throw new Error('fail'); } },
241
+ }, api);
242
+ registry.register({
243
+ name: 'good-transform',
244
+ hooks: { transformCopyContent: (text: string) => text + '-suffix' },
245
+ }, api);
246
+
247
+ // The bad transform throws, so the input to the good transform is still the original
248
+ const result = registry.callTransformHook('input', ctx);
249
+ expect(result).toBe('input-suffix');
250
+ expect(warnSpy).toHaveBeenCalledTimes(1);
251
+ warnSpy.mockRestore();
252
+ });
253
+ });
254
+
255
+ describe('dispose', () => {
256
+ it('calls all cleanups and clears plugins', () => {
257
+ const registry = createPluginRegistry();
258
+ const api = makeMockApi();
259
+ const cleanup1 = vi.fn();
260
+ const cleanup2 = vi.fn();
261
+
262
+ registry.register({ name: 'p1', setup: () => cleanup1 }, api);
263
+ registry.register({ name: 'p2', setup: () => cleanup2 }, api);
264
+
265
+ registry.dispose();
266
+
267
+ expect(cleanup1).toHaveBeenCalledTimes(1);
268
+ expect(cleanup2).toHaveBeenCalledTimes(1);
269
+ expect(registry.getPlugins()).toHaveLength(0);
270
+ });
271
+
272
+ it('ignores errors during cleanup', () => {
273
+ const registry = createPluginRegistry();
274
+ const api = makeMockApi();
275
+
276
+ registry.register({
277
+ name: 'err',
278
+ setup: () => () => { throw new Error('cleanup fail'); },
279
+ }, api);
280
+
281
+ // Should not throw
282
+ expect(() => registry.dispose()).not.toThrow();
283
+ expect(registry.getPlugins()).toHaveLength(0);
284
+ });
285
+ });
286
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createStore } from '../store';
3
+ import type { AngularGrabOptions } from '../types';
4
+
5
+ function makeOptions(overrides: Partial<AngularGrabOptions> = {}): AngularGrabOptions {
6
+ return {
7
+ activationKey: 'Meta+C',
8
+ activationMode: 'hold',
9
+ keyHoldDuration: 0,
10
+ maxContextLines: 20,
11
+ enabled: true,
12
+ enableInInputs: false,
13
+ devOnly: true,
14
+ showToolbar: true,
15
+ themeMode: 'dark',
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe('createStore', () => {
21
+ it('creates state with correct default values', () => {
22
+ const opts = makeOptions();
23
+ const store = createStore(opts);
24
+
25
+ expect(store.state.active).toBe(false);
26
+ expect(store.state.frozen).toBe(false);
27
+ expect(store.state.hoveredElement).toBeNull();
28
+ expect(store.state.options).toBe(opts);
29
+ expect(store.state.toolbar.visible).toBe(true);
30
+ expect(store.state.toolbar.themeMode).toBe('dark');
31
+ expect(store.state.toolbar.history).toEqual([]);
32
+ expect(store.state.toolbar.pendingAction).toBeNull();
33
+ });
34
+
35
+ it('respects showToolbar=false in options', () => {
36
+ const store = createStore(makeOptions({ showToolbar: false }));
37
+ expect(store.state.toolbar.visible).toBe(false);
38
+ });
39
+
40
+ it('respects themeMode in options', () => {
41
+ const store = createStore(makeOptions({ themeMode: 'light' }));
42
+ expect(store.state.toolbar.themeMode).toBe('light');
43
+ });
44
+
45
+ it('notifies listeners on state changes', () => {
46
+ const store = createStore(makeOptions());
47
+ const listener = vi.fn();
48
+
49
+ store.subscribe(listener);
50
+ store.state.active = true;
51
+
52
+ expect(listener).toHaveBeenCalledTimes(1);
53
+ expect(listener).toHaveBeenCalledWith(store.state, 'active');
54
+ });
55
+
56
+ it('does not notify listeners when value has not changed', () => {
57
+ const store = createStore(makeOptions());
58
+ const listener = vi.fn();
59
+
60
+ store.subscribe(listener);
61
+ store.state.active = false; // same as default
62
+
63
+ expect(listener).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it('subscribe returns an unsubscribe function that works', () => {
67
+ const store = createStore(makeOptions());
68
+ const listener = vi.fn();
69
+
70
+ const unsub = store.subscribe(listener);
71
+ store.state.active = true;
72
+ expect(listener).toHaveBeenCalledTimes(1);
73
+
74
+ unsub();
75
+ store.state.active = false;
76
+ expect(listener).toHaveBeenCalledTimes(1); // no additional call
77
+ });
78
+
79
+ it('supports multiple listeners', () => {
80
+ const store = createStore(makeOptions());
81
+ const listener1 = vi.fn();
82
+ const listener2 = vi.fn();
83
+
84
+ store.subscribe(listener1);
85
+ store.subscribe(listener2);
86
+ store.state.frozen = true;
87
+
88
+ expect(listener1).toHaveBeenCalledTimes(1);
89
+ expect(listener2).toHaveBeenCalledTimes(1);
90
+ });
91
+
92
+ it('unsubscribing one listener does not affect others', () => {
93
+ const store = createStore(makeOptions());
94
+ const listener1 = vi.fn();
95
+ const listener2 = vi.fn();
96
+
97
+ const unsub1 = store.subscribe(listener1);
98
+ store.subscribe(listener2);
99
+
100
+ unsub1();
101
+ store.state.active = true;
102
+
103
+ expect(listener1).not.toHaveBeenCalled();
104
+ expect(listener2).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it('passes the proxy state and changed key to listeners', () => {
108
+ const store = createStore(makeOptions());
109
+ const listener = vi.fn();
110
+
111
+ store.subscribe(listener);
112
+ store.state.frozen = true;
113
+
114
+ const [receivedState, receivedKey] = listener.mock.calls[0];
115
+ expect(receivedKey).toBe('frozen');
116
+ expect(receivedState.frozen).toBe(true);
117
+ });
118
+ });
@@ -0,0 +1,85 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest';
3
+ import { escapeHtml, cleanAngularAttrs } from '../utils';
4
+
5
+ describe('escapeHtml', () => {
6
+ it('escapes ampersands', () => {
7
+ expect(escapeHtml('foo & bar')).toBe('foo &amp; bar');
8
+ });
9
+
10
+ it('escapes angle brackets', () => {
11
+ expect(escapeHtml('<div>')).toBe('&lt;div&gt;');
12
+ });
13
+
14
+ it('passes through double quotes (not special in text nodes)', () => {
15
+ // innerHTML only escapes <, >, & in text content — not quotes
16
+ expect(escapeHtml('"hello"')).toBe('"hello"');
17
+ });
18
+
19
+ it('passes through single quotes (not special in text nodes)', () => {
20
+ expect(escapeHtml("it's")).toBe("it's");
21
+ });
22
+
23
+ it('escapes multiple special characters together', () => {
24
+ expect(escapeHtml('<a href="x">&</a>')).toBe(
25
+ '&lt;a href="x"&gt;&amp;&lt;/a&gt;',
26
+ );
27
+ });
28
+
29
+ it('returns empty string for empty input', () => {
30
+ expect(escapeHtml('')).toBe('');
31
+ });
32
+
33
+ it('returns plain text unchanged', () => {
34
+ expect(escapeHtml('hello world')).toBe('hello world');
35
+ });
36
+ });
37
+
38
+ describe('cleanAngularAttrs', () => {
39
+ it('removes _nghost attributes with values', () => {
40
+ const html = '<div _nghost-abc-123=""></div>';
41
+ expect(cleanAngularAttrs(html)).toBe('<div></div>');
42
+ });
43
+
44
+ it('removes _ngcontent attributes with values', () => {
45
+ const html = '<span _ngcontent-xyz-456=""></span>';
46
+ expect(cleanAngularAttrs(html)).toBe('<span></span>');
47
+ });
48
+
49
+ it('removes _nghost attributes without values', () => {
50
+ const html = '<div _nghost-abc-123></div>';
51
+ expect(cleanAngularAttrs(html)).toBe('<div></div>');
52
+ });
53
+
54
+ it('removes _ngcontent attributes without values', () => {
55
+ const html = '<span _ngcontent-xyz-456></span>';
56
+ expect(cleanAngularAttrs(html)).toBe('<span></span>');
57
+ });
58
+
59
+ it('removes multiple Angular attributes from one tag', () => {
60
+ const html = '<div _nghost-abc-123="" _ngcontent-xyz-456="">text</div>';
61
+ expect(cleanAngularAttrs(html)).toBe('<div>text</div>');
62
+ });
63
+
64
+ it('removes Angular attributes from nested elements', () => {
65
+ const html =
66
+ '<div _nghost-a-1=""><span _ngcontent-b-2="">hi</span></div>';
67
+ expect(cleanAngularAttrs(html)).toBe('<div><span>hi</span></div>');
68
+ });
69
+
70
+ it('preserves non-Angular attributes', () => {
71
+ const html = '<div class="foo" id="bar" _nghost-abc-123="">text</div>';
72
+ expect(cleanAngularAttrs(html)).toBe(
73
+ '<div class="foo" id="bar">text</div>',
74
+ );
75
+ });
76
+
77
+ it('returns unchanged HTML when no Angular attributes present', () => {
78
+ const html = '<div class="test">hello</div>';
79
+ expect(cleanAngularAttrs(html)).toBe(html);
80
+ });
81
+
82
+ it('handles empty string', () => {
83
+ expect(cleanAngularAttrs('')).toBe('');
84
+ });
85
+ });
@@ -0,0 +1,104 @@
1
+ import type { ElementContext, ComponentStackEntry, ComponentResolver, SourceResolver } from '../types';
2
+ import type { PluginRegistry } from '../plugins/plugin-registry';
3
+ import { generateSnippet } from './generate-snippet';
4
+ import { showToast, type ToastDetail } from '../overlay/toast';
5
+ import { filterAngularClasses } from '../utils';
6
+
7
+ export interface CopyDeps {
8
+ getComponentResolver: () => ComponentResolver | null;
9
+ getSourceResolver: () => SourceResolver | null;
10
+ getMaxContextLines: () => number;
11
+ pluginRegistry: PluginRegistry;
12
+ }
13
+
14
+ function buildSelector(el: Element): string {
15
+ const tag = el.tagName.toLowerCase();
16
+ const id = el.id ? `#${el.id}` : '';
17
+ const classes = filterAngularClasses(el.classList)
18
+ .map((c) => `.${c}`)
19
+ .join('');
20
+ return `${tag}${id}${classes}`;
21
+ }
22
+
23
+ function getCssClasses(el: Element): string[] {
24
+ return filterAngularClasses(el.classList);
25
+ }
26
+
27
+ export function buildElementContext(
28
+ element: Element,
29
+ componentResolver: ComponentResolver | null,
30
+ sourceResolver: SourceResolver | null,
31
+ ): ElementContext {
32
+ const compResult = componentResolver?.(element);
33
+ const srcResult = sourceResolver?.(element);
34
+
35
+ // Build component stack from resolver result
36
+ const componentStack: ComponentStackEntry[] = [];
37
+ if (compResult?.stack) {
38
+ for (const entry of compResult.stack) {
39
+ let filePath: string | null = null;
40
+ let line: number | null = null;
41
+ let column: number | null = null;
42
+
43
+ if (entry.hostElement && sourceResolver) {
44
+ const src = sourceResolver(entry.hostElement);
45
+ if (src) {
46
+ filePath = src.filePath;
47
+ line = src.line;
48
+ column = src.column;
49
+ }
50
+ }
51
+
52
+ componentStack.push({ name: entry.name, filePath, line, column });
53
+ }
54
+ }
55
+
56
+ return {
57
+ element,
58
+ html: element.outerHTML,
59
+ componentName: compResult?.name ?? null,
60
+ filePath: srcResult?.filePath ?? null,
61
+ line: srcResult?.line ?? null,
62
+ column: srcResult?.column ?? null,
63
+ componentStack,
64
+ selector: buildSelector(element),
65
+ cssClasses: getCssClasses(element),
66
+ };
67
+ }
68
+
69
+ export interface CopyResult {
70
+ context: ElementContext;
71
+ snippet: string;
72
+ }
73
+
74
+ export async function copyElement(element: Element, deps: CopyDeps): Promise<CopyResult | null> {
75
+ const context = buildElementContext(
76
+ element,
77
+ deps.getComponentResolver(),
78
+ deps.getSourceResolver(),
79
+ );
80
+
81
+ deps.pluginRegistry.callHook('onElementSelect', context);
82
+ deps.pluginRegistry.callHook('onBeforeCopy', context);
83
+
84
+ let snippet = generateSnippet(context, deps.getMaxContextLines());
85
+ snippet = deps.pluginRegistry.callTransformHook(snippet, context);
86
+
87
+ try {
88
+ await navigator.clipboard.writeText(snippet);
89
+ const detail: ToastDetail = {
90
+ componentName: context.componentName,
91
+ filePath: context.filePath,
92
+ line: context.line,
93
+ column: context.column,
94
+ cssClasses: context.cssClasses,
95
+ };
96
+ showToast('Copied to clipboard', detail);
97
+ deps.pluginRegistry.callHook('onCopySuccess', snippet, context, undefined);
98
+ return { context, snippet };
99
+ } catch (err) {
100
+ const error = err instanceof Error ? err : new Error(String(err));
101
+ deps.pluginRegistry.callHook('onCopyError', error);
102
+ return null;
103
+ }
104
+ }
@@ -0,0 +1,38 @@
1
+ import type { ElementContext } from '../types';
2
+ import { cleanAngularAttrs } from '../utils';
3
+
4
+ function truncateHtml(html: string, maxLines: number): string {
5
+ const lines = html.split('\n');
6
+ if (lines.length <= maxLines) return html;
7
+
8
+ return lines.slice(0, maxLines).join('\n') + '\n ...';
9
+ }
10
+
11
+ function formatLocation(name: string | null, filePath: string | null, line: number | null, column: number | null): string {
12
+ let locationLine = '';
13
+ if (name) locationLine += `in ${name}`;
14
+ if (filePath) {
15
+ const loc = filePath +
16
+ (line != null ? `:${line}` : '') +
17
+ (line != null && column != null ? `:${column}` : '');
18
+ locationLine += locationLine ? ` at ${loc}` : `at ${loc}`;
19
+ }
20
+ return locationLine;
21
+ }
22
+
23
+ export function generateSnippet(context: ElementContext, maxContextLines: number): string {
24
+ const cleaned = cleanAngularAttrs(context.html);
25
+ const truncated = truncateHtml(cleaned, maxContextLines);
26
+
27
+ const parts: string[] = [truncated];
28
+
29
+ if (context.componentStack.length > 0) {
30
+ for (const entry of context.componentStack) {
31
+ parts.push(formatLocation(entry.name, entry.filePath, entry.line, entry.column));
32
+ }
33
+ } else if (context.componentName || context.filePath) {
34
+ parts.push(formatLocation(context.componentName, context.filePath, context.line, context.column));
35
+ }
36
+
37
+ return parts.join('\n');
38
+ }
@@ -0,0 +1,10 @@
1
+ export const Z_INDEX_FREEZE = 2147483644;
2
+ export const Z_INDEX_CROSSHAIR = 2147483645;
3
+ export const Z_INDEX_OVERLAY = 2147483646;
4
+ export const Z_INDEX_LABEL = 2147483647;
5
+ export const Z_INDEX_TOOLBAR = 2147483646;
6
+ export const Z_INDEX_TOAST = 2147483647;
7
+ export const Z_INDEX_POPOVER = 2147483647;
8
+
9
+ export const TOOLBAR_TOAST_OFFSET = '72px';
10
+ export const TOOLBAR_POPOVER_OFFSET = '68px';