wally-ui 1.14.0 → 1.15.0

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 (23) hide show
  1. package/package.json +1 -1
  2. package/playground/showcase/public/sitemap.xml +15 -0
  3. package/playground/showcase/src/app/app.routes.server.ts +4 -0
  4. package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.html +11 -2
  5. package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.ts +13 -3
  6. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.css +0 -0
  7. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.html +41 -0
  8. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.spec.ts +16 -0
  9. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.ts +175 -0
  10. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.spec.ts +23 -0
  11. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.ts +64 -0
  12. package/playground/showcase/src/app/components/selection-popover/selection-popover.html +8 -2
  13. package/playground/showcase/src/app/components/selection-popover/selection-popover.ts +76 -12
  14. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.css +1 -0
  15. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.examples.ts +146 -0
  16. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.html +576 -0
  17. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.ts +124 -0
  18. package/playground/showcase/src/app/pages/documentation/components/components.html +27 -0
  19. package/playground/showcase/src/app/pages/documentation/components/components.routes.ts +4 -0
  20. package/playground/showcase/src/app/pages/documentation/components/selection-popover-docs/selection-popover-docs.examples.ts +4 -0
  21. package/playground/showcase/src/app/pages/documentation/components/selection-popover-docs/selection-popover-docs.html +49 -0
  22. package/playground/showcase/src/app/pages/documentation/components/selection-popover-docs/selection-popover-docs.ts +1 -0
  23. package/playground/showcase/src/app/pages/home/home.html +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wally-ui",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "About Where’s Wally? Right here — bringing you ready-to-use Angular components with Wally-UI. Stop searching, start building.",
5
5
  "bin": {
6
6
  "wally": "dist/cli.js"
@@ -68,6 +68,21 @@
68
68
  <priority>0.8</priority>
69
69
  </url>
70
70
 
71
+ <url>
72
+ <loc>https://wally-ui.com/documentation/components/selection-popover</loc>
73
+ <lastmod>2025-10-18</lastmod>
74
+ <changefreq>monthly</changefreq>
75
+ <priority>0.8</priority>
76
+ </url>
77
+
78
+ <!-- Audio Waveform Documentation -->
79
+ <url>
80
+ <loc>https://wally-ui.com/documentation/components/audio-waveform</loc>
81
+ <lastmod>2025-10-18</lastmod>
82
+ <changefreq>monthly</changefreq>
83
+ <priority>0.8</priority>
84
+ </url>
85
+
71
86
  <!-- Chat SDK Documentation -->
72
87
  <url>
73
88
  <loc>https://wally-ui.com/documentation/chat-sdk</loc>
@@ -37,6 +37,10 @@ export const serverRoutes: ServerRoute[] = [
37
37
  path: 'documentation/components/selection-popover',
38
38
  renderMode: RenderMode.Prerender,
39
39
  },
40
+ {
41
+ path: 'documentation/components/audio-waveform',
42
+ renderMode: RenderMode.Prerender,
43
+ },
40
44
  {
41
45
  path: 'documentation/chat-sdk',
42
46
  renderMode: RenderMode.Prerender,
@@ -35,10 +35,18 @@
35
35
  </div>
36
36
  }
37
37
 
38
- <div class="w-full px-4 pt-4">
38
+ <div class="w-full px-4 pt-4 transition-all duration-1000 ease-in-out overflow-hidden" [class.opacity-0]="isStartRecoding()" [class.opacity-100]="!isStartRecoding()"
39
+ [class.max-h-0]="isStartRecoding()" [class.max-h-96]="!isStartRecoding()">
39
40
  <wally-ai-prompt-input></wally-ai-prompt-input>
40
41
  </div>
41
42
 
43
+ <div class="w-full transition-all duration-700 ease-in-out overflow-hidden" [class.opacity-0]="!isStartRecoding()"
44
+ [class.opacity-100]="isStartRecoding()" [class.scale-95]="!isStartRecoding()"
45
+ [class.scale-100]="isStartRecoding()" [class.max-h-0]="!isStartRecoding()" [class.max-h-32]="isStartRecoding()">
46
+ <wally-audio-waveform [isStartRecording]="isStartRecoding()" [isStopRecording]="isStopRecoding()">
47
+ </wally-audio-waveform>
48
+ </div>
49
+
42
50
  <!-- Action Buttons -->
43
51
  <div class="w-full flex items-center justify-between p-1" role="toolbar" aria-label="Composer actions">
44
52
  <div class="w-full flex items-center gap-1">
@@ -186,7 +194,8 @@
186
194
  <div class="w-full size-10 flex gap-2 justify-end">
187
195
  <div>
188
196
  <wally-tooltip [text]="'Use voice input'" position="bottom">
189
- <wally-button [variant]="'ghost'" [rounded]="true" [ariaLabel]="'Send message'">
197
+ <wally-button (buttonClick)="toggleInputVoice()" [variant]="'ghost'" [rounded]="true"
198
+ [ariaLabel]="'Send message'">
190
199
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
191
200
  stroke="currentColor" class="size-6">
192
201
  <path stroke-linecap="round" stroke-linejoin="round"
@@ -1,4 +1,4 @@
1
- import { Component, input } from '@angular/core';
1
+ import { Component, input, InputSignal, signal, WritableSignal } from '@angular/core';
2
2
 
3
3
  import { DropdownMenuSubTrigger } from '../../dropdown-menu/dropdown-menu-sub-trigger/dropdown-menu-sub-trigger';
4
4
  import { DropdownMenuSubContent } from '../../dropdown-menu/dropdown-menu-sub-content/dropdown-menu-sub-content';
@@ -9,6 +9,7 @@ import { DropdownMenuLabel } from '../../dropdown-menu/dropdown-menu-label/dropd
9
9
  import { DropdownMenuGroup } from '../../dropdown-menu/dropdown-menu-group/dropdown-menu-group';
10
10
  import { DropdownMenuItem } from '../../dropdown-menu/dropdown-menu-item/dropdown-menu-item';
11
11
  import { DropdownMenuSub } from '../../dropdown-menu/dropdown-menu-sub/dropdown-menu-sub';
12
+ import { AudioWaveform } from '../../audio-waveform/audio-waveform';
12
13
  import { AiPromptInput } from '../ai-prompt-input/ai-prompt-input';
13
14
  import { DropdownMenu } from '../../dropdown-menu/dropdown-menu';
14
15
  import { Tooltip } from '../../tooltip/tooltip';
@@ -29,15 +30,24 @@ import { Button } from '../../button/button';
29
30
  DropdownMenuGroup,
30
31
  DropdownMenuSub,
31
32
  DropdownMenuSubTrigger,
32
- DropdownMenuSubContent
33
+ DropdownMenuSubContent,
34
+ AudioWaveform
33
35
  ],
34
36
  templateUrl: './ai-composer.html',
35
37
  styleUrl: './ai-composer.css'
36
38
  })
37
39
  export class AiComposer {
38
- textSelected = input<string>('');
40
+ textSelected: InputSignal<string> = input<string>('');
41
+
42
+ isStartRecoding: WritableSignal<boolean> = signal<boolean>(false);
43
+ isStopRecoding: WritableSignal<boolean> = signal<boolean>(true);
39
44
 
40
45
  onItemClick(): void {
41
46
  console.log('Item clicked');
42
47
  }
48
+
49
+ toggleInputVoice(): void {
50
+ this.isStartRecoding.set(!this.isStartRecoding());
51
+ this.isStopRecoding.set(!this.isStartRecoding());
52
+ }
43
53
  }
@@ -0,0 +1,41 @@
1
+ <div class="w-full flex flex-col items-center gap-4 p-3">
2
+
3
+ <!-- Waveform Bars Container -->
4
+ @if (isStartRecording()) {
5
+ <div class="flex items-center justify-center gap-1 h-11 w-full">
6
+ @if (audioWaveformService.isRecording()) {
7
+ <!-- Active Recording: Animated Bars -->
8
+ @for (barHeight of audioWaveformService.audioData(); track $index) {
9
+ <div class="flex-1 min-w-[2px] max-w-[7px] bg-[#0a0a0a] dark:bg-white rounded-full transition-all duration-200 ease-out"
10
+ [style.height.%]="barHeight || 5" [style.min-height.px]="4">
11
+ </div>
12
+ }
13
+ }
14
+
15
+ </div>
16
+ }
17
+
18
+ <!-- Recording Timer -->
19
+ @if (showTimer() && audioWaveformService.isRecording()) {
20
+ <div class="text-sm text-[#0a0a0a] dark:text-white">
21
+ {{ formattedTime }}
22
+ </div>
23
+ }
24
+
25
+ <!-- Controls -->
26
+ <!-- <div class="flex gap-2">
27
+ @if (!audioWaveformService.isRecording()) {
28
+ <button (click)="startRecording()"
29
+ class="px-4 py-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
30
+ aria-label="Start recording">
31
+ Start Recording
32
+ </button>
33
+ } @else {
34
+ <button (click)="stopRecording()"
35
+ class="px-4 py-2 bg-neutral-500 text-white rounded-full hover:bg-neutral-600 transition-colors"
36
+ aria-label="Stop recording">
37
+ Stop Recording
38
+ </button>
39
+ }
40
+ </div> -->
41
+ </div>
@@ -0,0 +1,16 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+
3
+ import { AudioWaveformService } from './audio-waveform.service';
4
+
5
+ describe('AudioWaveformService', () => {
6
+ let service: AudioWaveformService;
7
+
8
+ beforeEach(() => {
9
+ TestBed.configureTestingModule({});
10
+ service = TestBed.inject(AudioWaveformService);
11
+ });
12
+
13
+ it('should be created', () => {
14
+ expect(service).toBeTruthy();
15
+ });
16
+ });
@@ -0,0 +1,175 @@
1
+ import { Injectable, signal, WritableSignal } from '@angular/core';
2
+
3
+ @Injectable()
4
+ export class AudioWaveformService {
5
+ isRecording: WritableSignal<boolean> = signal<boolean>(false);
6
+ audioData: WritableSignal<number[]> = signal<number[]>([]); // Frequency values for bars
7
+
8
+ // Recorded audio signals
9
+ recordedAudioBlob: WritableSignal<Blob | null> = signal<Blob | null>(null);
10
+ recordedAudioUrl: WritableSignal<string | null> = signal<string | null>(null);
11
+
12
+ // Audio API references
13
+ private audioContext: AudioContext | null = null;
14
+ private analyser: AnalyserNode | null = null;
15
+ private microphone: MediaStreamAudioSourceNode | null = null;
16
+ private animationFrame: number | null = null;
17
+ private stream: MediaStream | null = null;
18
+
19
+ // Configuration
20
+ private readonly FFT_SIZE = 256; // Higher = more frequency detail
21
+ private readonly SMOOTHING = 0.8; // 0 (no smoothing) to 1 (max smoothing)
22
+
23
+ // Responsive bar count based on screen width
24
+ private get BAR_COUNT(): number {
25
+ if (typeof window === 'undefined') return 30; // SSR fallback
26
+ return window.innerWidth < 768 ? 30 : 65; // Mobile: 30 bars, Desktop: 65 bars
27
+ }
28
+
29
+ // MediaRecorded
30
+ private mediaRecorder: MediaRecorder | null = null;
31
+ private audioChunks: Blob[] = [];
32
+
33
+ async startRecording(): Promise<void> {
34
+ try {
35
+ // Request microphone access
36
+ this.stream = await navigator.mediaDevices.getUserMedia({
37
+ audio: {
38
+ echoCancellation: true,
39
+ noiseSuppression: true,
40
+ autoGainControl: true
41
+ }
42
+ });
43
+
44
+ // Create audio context
45
+ this.audioContext = new AudioContext();
46
+
47
+ // Create analyzer
48
+ this.analyser = this.audioContext.createAnalyser();
49
+ this.analyser.fftSize = this.FFT_SIZE;
50
+ this.analyser.smoothingTimeConstant = this.SMOOTHING;
51
+
52
+ // Connect microphone to analyzer
53
+ this.microphone = this.audioContext.createMediaStreamSource(this.stream);
54
+ this.microphone?.connect(this.analyser);
55
+
56
+ // Init MediaRecorded
57
+ this.audioChunks = [];
58
+ this.mediaRecorder = new MediaRecorder(this.stream);
59
+
60
+ this.mediaRecorder.ondataavailable = (event) => {
61
+ if (event.data.size > 0) {
62
+ this.audioChunks.push(event.data);
63
+ }
64
+ };
65
+
66
+ this.mediaRecorder.onstop = () => {
67
+ const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
68
+ this.recordedAudioBlob.set(audioBlob);
69
+
70
+ const audioUrl = URL.createObjectURL(audioBlob);
71
+ this.recordedAudioUrl.set(audioUrl);
72
+ };
73
+
74
+ this.mediaRecorder.start();
75
+
76
+ // Start animation loop
77
+ this.isRecording.set(true);
78
+ this.updateWaveForm();
79
+
80
+ } catch (error) {
81
+ console.error('Microphone access denied:', error);
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ stopRecording(): void {
87
+ // Stop animation
88
+ if (this.animationFrame) {
89
+ cancelAnimationFrame(this.animationFrame);
90
+ }
91
+
92
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
93
+ this.mediaRecorder.stop();
94
+ }
95
+
96
+ // Disconnect audio nodes
97
+ this.microphone?.disconnect();
98
+ this.audioContext?.close();
99
+
100
+ // Stop microphone strem
101
+ this.stream?.getTracks().forEach(track => track.stop());
102
+
103
+ // Reset state
104
+ this.isRecording.set(false);
105
+ this.audioData.set([]);
106
+
107
+ // Clean up references
108
+ this.audioContext = null;
109
+ this.analyser = null;
110
+ this.microphone = null;
111
+ this.stream = null;
112
+ this.mediaRecorder = null;
113
+ }
114
+
115
+ // Download recorded audio
116
+ downloadRecording(filename: string = `recording-${Date.now()}.webm`): void {
117
+ const url = this.recordedAudioUrl();
118
+
119
+ if (!url) {
120
+ console.error('No recorded audio available');
121
+ return;
122
+ }
123
+
124
+ const a = document.createElement('a');
125
+ a.href = url;
126
+ a.download = filename;
127
+ a.click();
128
+ }
129
+
130
+ // Clear recorded audio and free memory
131
+ clearRecording(): void {
132
+ // Revoke URL to free memory
133
+ const url = this.recordedAudioUrl();
134
+ if (url) {
135
+ URL.revokeObjectURL(url);
136
+ }
137
+
138
+ this.recordedAudioBlob.set(null);
139
+ this.recordedAudioUrl.set(null);
140
+ this.audioChunks = [];
141
+ }
142
+
143
+ private updateWaveForm(): void {
144
+ if (!this.analyser || !this.isRecording()) return;
145
+
146
+ // Get frequency data
147
+ const bufferLength: number = this.analyser.frequencyBinCount; // 128 (FFT_SIZE/2)
148
+ const dataArray: Uint8Array<ArrayBuffer> = new Uint8Array(bufferLength);
149
+ this.analyser.getByteFrequencyData(dataArray); // Values 0-255
150
+
151
+ // Reduce to BAR_COUNT bars (average frequencies)
152
+ const bars: number[] = [];
153
+ const samplesPerBar: number = Math.floor(bufferLength / this.BAR_COUNT);
154
+
155
+ for (let i = 0; i < this.BAR_COUNT; i++) {
156
+ const start = i * samplesPerBar;
157
+ const end = start + samplesPerBar;
158
+
159
+ // Average frequency values for this bar
160
+ let sum = 0;
161
+ for (let j = start; j < end; j++) {
162
+ sum += dataArray[j];
163
+ }
164
+ const average = sum / samplesPerBar;
165
+
166
+ // Normalize to 0-100 range for height percentage
167
+ const normalized = (average / 255) * 100;
168
+ bars.push(normalized);
169
+ }
170
+
171
+ this.audioData.set(bars);
172
+
173
+ this.animationFrame = requestAnimationFrame(() => this.updateWaveForm());
174
+ }
175
+ }
@@ -0,0 +1,23 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { AudioWaveform } from './audio-waveform';
4
+
5
+ describe('AudioWaveform', () => {
6
+ let component: AudioWaveform;
7
+ let fixture: ComponentFixture<AudioWaveform>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ imports: [AudioWaveform]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(AudioWaveform);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
@@ -0,0 +1,64 @@
1
+ import { Component, effect, input, InputSignal, signal, WritableSignal } from '@angular/core';
2
+
3
+ import { AudioWaveformService } from './audio-waveform.service';
4
+
5
+ @Component({
6
+ selector: 'wally-audio-waveform',
7
+ imports: [],
8
+ providers: [AudioWaveformService],
9
+ templateUrl: './audio-waveform.html',
10
+ styleUrl: './audio-waveform.css'
11
+ })
12
+ export class AudioWaveform {
13
+ isStartRecording: InputSignal<boolean> = input<boolean>(false);
14
+ isStopRecording: InputSignal<boolean> = input<boolean>(false);
15
+ showTimer: InputSignal<boolean> = input<boolean>(false);
16
+
17
+ recordingTime: WritableSignal<number> = signal<number>(0);
18
+ private timerInterval: any = null;
19
+
20
+ constructor(
21
+ public audioWaveformService: AudioWaveformService
22
+ ) {
23
+ effect(() => {
24
+ if (this.isStartRecording()) {
25
+ this.startRecording();
26
+ }
27
+ });
28
+ effect(() => {
29
+ if (this.isStopRecording()) {
30
+ this.stopRecording();
31
+ }
32
+ });
33
+ }
34
+
35
+ async startRecording(): Promise<void> {
36
+ try {
37
+ await this.audioWaveformService.startRecording();
38
+
39
+ this.recordingTime.set(0);
40
+ this.timerInterval = setInterval(() => {
41
+ this.recordingTime.update(time => time + 1);
42
+ }, 1000);
43
+ } catch (error) {
44
+ console.error('Failed to start recording:', error);
45
+ }
46
+ }
47
+
48
+ stopRecording(): void {
49
+ this.audioWaveformService.stopRecording();
50
+
51
+ // Stop timer
52
+ if (this.timerInterval) {
53
+ clearInterval(this.timerInterval);
54
+ }
55
+ }
56
+
57
+ // Format seconds to MM:SS
58
+ get formattedTime(): string {
59
+ const time = this.recordingTime();
60
+ const minutes = Math.floor(time / 60);
61
+ const seconds = time % 60;
62
+ return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
63
+ }
64
+ }
@@ -5,7 +5,9 @@
5
5
  <!-- Floating popover -->
6
6
  @if (isVisible()) {
7
7
  <div #popover
8
- class="fixed z-50 bg-white dark:bg-neutral-900 shadow-lg rounded-xl border border-neutral-300 dark:border-neutral-700 p-1 transition-opacity duration-150"
8
+ class="fixed z-50 bg-white dark:bg-neutral-900 shadow-lg rounded-xl border border-neutral-300 dark:border-neutral-700 transition-opacity duration-150"
9
+ [class.p-1]="!isMobile()"
10
+ [class.p-2]="isMobile()"
9
11
  [class.opacity-0]="!isPositioned()"
10
12
  [class.opacity-100]="isPositioned()"
11
13
  [style.top.px]="adjustedPosition().top" [style.left.px]="adjustedPosition().left" role="dialog"
@@ -19,7 +21,11 @@
19
21
 
20
22
  <!-- Fallback: default button (hidden if custom actions exist) -->
21
23
  <button [class.hidden]="hasCustomActionsSignal()"
22
- class="px-3 py-2 text-[#0a0a0a] text-sm font-mono hover:bg-neutral-100 dark:text-white dark:hover:bg-neutral-800 rounded transition-colors cursor-pointer">
24
+ class="text-[#0a0a0a] text-sm font-mono hover:bg-neutral-100 dark:text-white dark:hover:bg-neutral-800 rounded transition-colors cursor-pointer"
25
+ [class.px-3]="!isMobile()"
26
+ [class.py-2]="!isMobile()"
27
+ [class.px-4]="isMobile()"
28
+ [class.py-3]="isMobile()">
23
29
  Default Action
24
30
  </button>
25
31
  </div>
@@ -48,44 +48,77 @@ export class SelectionPopover implements AfterViewInit {
48
48
  /** Currently selected text */
49
49
  selectedText: WritableSignal<string> = signal<string>('');
50
50
 
51
+ /**
52
+ * Detects if user is on mobile device
53
+ */
54
+ isMobile = computed(() => {
55
+ if (typeof window === 'undefined') return false;
56
+
57
+ return 'ontouchstart' in window ||
58
+ navigator.maxTouchPoints > 0 ||
59
+ window.innerWidth < 768;
60
+ });
61
+
51
62
  /**
52
63
  * Computes adjusted position with viewport constraints
53
64
  * Prevents popover from overflowing screen edges
65
+ * Mobile-aware: accounts for virtual keyboard and smaller screens
54
66
  */
55
67
  adjustedPosition = computed(() => {
56
68
  const position = this.popoverPosition();
57
69
  const viewportWidth = window.innerWidth;
70
+ const viewportHeight = window.innerHeight;
58
71
 
59
- // Get real popover width if available, otherwise estimate
72
+ // Get real popover dimensions if available, otherwise estimate
60
73
  const popoverWidth = this.popoverElement?.nativeElement?.offsetWidth || 200;
74
+ const popoverHeight = this.popoverElement?.nativeElement?.offsetHeight || 50;
61
75
 
62
76
  let left = position.left;
77
+ let top = position.top;
63
78
 
64
- // Prevent overflow on the right edge
79
+ // Horizontal adjustment
65
80
  if (left + popoverWidth > viewportWidth) {
66
81
  left = viewportWidth - popoverWidth - 10;
67
82
  }
68
-
69
- // Prevent overflow on the left edge
70
83
  if (left < 10) {
71
84
  left = 10;
72
85
  }
73
86
 
74
- return {
75
- top: position.top,
76
- left: left
77
- };
87
+ // Vertical adjustment for mobile (avoid keyboard and screen edges)
88
+ if (this.isMobile()) {
89
+ // If too close to top, position below selection instead
90
+ if (top < 80) {
91
+ const selection = window.getSelection();
92
+ if (selection && selection.rangeCount > 0) {
93
+ const range = selection.getRangeAt(0);
94
+ const rect = range.getBoundingClientRect();
95
+ top = rect.bottom + 10; // 10px below selection
96
+ }
97
+ }
98
+
99
+ // If too close to bottom (virtual keyboard area), keep visible
100
+ if (top + popoverHeight > viewportHeight - 100) {
101
+ top = viewportHeight - popoverHeight - 100;
102
+ }
103
+ }
104
+
105
+ return { top, left };
78
106
  });
79
107
 
80
108
  /**
81
- * Handles mouseup event to detect text selection
82
- * Uses setTimeout to ensure selection is complete
109
+ * Handles both mouse and touch selection events
110
+ * Mobile: touchend after long-press selection
111
+ * Desktop: mouseup after click-drag selection
83
112
  */
84
113
  @HostListener('mouseup', ['$event'])
85
- onMouseUp(event: MouseEvent) {
114
+ @HostListener('touchend', ['$event'])
115
+ onMouseUp(event: MouseEvent | TouchEvent): void {
116
+ const isMobile = 'ontouchstart' in window;
117
+ const delay = isMobile ? 100 : 10;
118
+
86
119
  setTimeout(() => {
87
120
  this.handleTextSelection();
88
- }, 10);
121
+ }, delay);
89
122
  }
90
123
 
91
124
  /**
@@ -111,6 +144,37 @@ export class SelectionPopover implements AfterViewInit {
111
144
  this.hide();
112
145
  }
113
146
 
147
+ /**
148
+ * Prevents native mobile selection menu from appearing
149
+ * This ensures only our custom popover is shown
150
+ */
151
+ @HostListener('selectionchange')
152
+ onNativeSelectionChange() {
153
+ const selection = window.getSelection();
154
+
155
+ if (selection && selection.toString().trim().length >= 3) {
156
+ // Prevent native menu only if valid selection exists
157
+ // and our popover will appear
158
+ event?.preventDefault?.();
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Prevents scroll when touching the popover on mobile
164
+ * Ensures users can interact with actions without accidentally scrolling
165
+ */
166
+ @HostListener('touchmove', ['$event'])
167
+ onTouchMove(event: TouchEvent): void {
168
+ if (this.isVisible() && this.popoverElement) {
169
+ const target = event.target as Node;
170
+ const isPopoverTouch = this.popoverElement.nativeElement.contains(target);
171
+
172
+ if (isPopoverTouch) {
173
+ event.preventDefault();
174
+ }
175
+ }
176
+ }
177
+
114
178
  /**
115
179
  * Handles text selection and shows popover
116
180
  *