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.
- package/package.json +1 -1
- package/playground/showcase/public/sitemap.xml +15 -0
- package/playground/showcase/src/app/app.routes.server.ts +4 -0
- package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.html +11 -2
- package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.ts +13 -3
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.css +0 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.html +41 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.spec.ts +16 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.ts +175 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.spec.ts +23 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.ts +64 -0
- package/playground/showcase/src/app/components/selection-popover/selection-popover.html +8 -2
- package/playground/showcase/src/app/components/selection-popover/selection-popover.ts +76 -12
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.css +1 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.examples.ts +146 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.html +576 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.ts +124 -0
- package/playground/showcase/src/app/pages/documentation/components/components.html +27 -0
- package/playground/showcase/src/app/pages/documentation/components/components.routes.ts +4 -0
- package/playground/showcase/src/app/pages/documentation/components/selection-popover-docs/selection-popover-docs.examples.ts +4 -0
- package/playground/showcase/src/app/pages/documentation/components/selection-popover-docs/selection-popover-docs.html +49 -0
- package/playground/showcase/src/app/pages/documentation/components/selection-popover-docs/selection-popover-docs.ts +1 -0
- package/playground/showcase/src/app/pages/home/home.html +1 -1
package/package.json
CHANGED
|
@@ -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"
|
|
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
|
}
|
|
File without changes
|
|
@@ -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
|
|
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="
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
82
|
-
*
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/* Audio Waveform Documentation Styles */
|