Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell. Co-authored-by: Cursor <cursoragent@cursor.com>
212 lines
4.9 KiB
Vue
212 lines
4.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, ref } from 'vue';
|
|
import { Button as AButton, Typography } from 'ant-design-vue';
|
|
import { AudioOutlined, PauseCircleOutlined } from '@/icons/ant-bridge';
|
|
import { toFaDigits } from '@/lib/jalali';
|
|
|
|
const { Text: ATypographyText } = Typography;
|
|
|
|
type SpeechResult = {
|
|
isFinal: boolean;
|
|
0: { transcript: string };
|
|
};
|
|
|
|
type SpeechResultEvent = {
|
|
resultIndex: number;
|
|
results: ArrayLike<SpeechResult>;
|
|
};
|
|
|
|
type SpeechRec = {
|
|
lang: string;
|
|
continuous: boolean;
|
|
interimResults: boolean;
|
|
start: () => void;
|
|
stop: () => void;
|
|
abort: () => void;
|
|
onresult: ((ev: SpeechResultEvent) => void) | null;
|
|
onerror: ((ev: { error: string }) => void) | null;
|
|
onend: (() => void) | null;
|
|
};
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
disabled?: boolean;
|
|
variant?: 'default' | 'toolbar';
|
|
hideError?: boolean;
|
|
}>(),
|
|
{ variant: 'default', hideError: false },
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
transcript: [text: string];
|
|
recording: [active: boolean];
|
|
busy: [active: boolean];
|
|
error: [message: string];
|
|
}>();
|
|
|
|
const UNSUPPORTED = 'تبدیل صوت به متن در این مرورگر پشتیبانی نمیشود. از Chrome استفاده کنید.';
|
|
const MAX_SECONDS = 90;
|
|
|
|
const recording = ref(false);
|
|
const error = ref('');
|
|
const seconds = ref(0);
|
|
|
|
let recognition: SpeechRec | null = null;
|
|
let timer: number | null = null;
|
|
let limitTimer: number | null = null;
|
|
let stopping = false;
|
|
let failed = false;
|
|
|
|
const timerLabel = computed(() => {
|
|
const m = String(Math.floor(seconds.value / 60)).padStart(2, '0');
|
|
const s = String(seconds.value % 60).padStart(2, '0');
|
|
return toFaDigits(`${m}:${s}`);
|
|
});
|
|
const ariaLabel = computed(() =>
|
|
recording.value ? 'توقف تبدیل صوت به متن' : 'تبدیل صوت به متن',
|
|
);
|
|
|
|
function speechCtor(): (new () => SpeechRec) | null {
|
|
const w = window as Window & {
|
|
SpeechRecognition?: new () => SpeechRec;
|
|
webkitSpeechRecognition?: new () => SpeechRec;
|
|
};
|
|
return w.SpeechRecognition || w.webkitSpeechRecognition || null;
|
|
}
|
|
|
|
function setError(message: string) {
|
|
error.value = message;
|
|
emit('error', message);
|
|
}
|
|
|
|
function setRecording(active: boolean) {
|
|
recording.value = active;
|
|
emit('recording', active);
|
|
emit('busy', active);
|
|
}
|
|
|
|
function stopTimer() {
|
|
if (timer != null) {
|
|
window.clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
if (limitTimer != null) {
|
|
window.clearTimeout(limitTimer);
|
|
limitTimer = null;
|
|
}
|
|
}
|
|
|
|
function stop() {
|
|
stopping = true;
|
|
stopTimer();
|
|
try {
|
|
recognition?.stop();
|
|
} catch {
|
|
/* already stopped */
|
|
}
|
|
recognition = null;
|
|
setRecording(false);
|
|
}
|
|
|
|
function start() {
|
|
setError('');
|
|
const Ctor = speechCtor();
|
|
if (!Ctor) {
|
|
setError(UNSUPPORTED);
|
|
return;
|
|
}
|
|
|
|
const rec = new Ctor();
|
|
rec.lang = 'fa-IR';
|
|
rec.continuous = true;
|
|
rec.interimResults = false;
|
|
rec.onresult = (event) => {
|
|
let text = '';
|
|
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
|
const row = event.results[i];
|
|
if (row?.isFinal) text += row[0]?.transcript ?? '';
|
|
}
|
|
const next = text.replace(/\s+/g, ' ').trim();
|
|
if (next) emit('transcript', next);
|
|
};
|
|
rec.onerror = (event) => {
|
|
if (event.error === 'aborted' || event.error === 'no-speech') return;
|
|
failed = true;
|
|
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
|
|
setError('دسترسی به میکروفون داده نشد.');
|
|
return;
|
|
}
|
|
if (event.error === 'network') {
|
|
setError('اتصال به تشخیص گفتار Chrome برقرار نشد.');
|
|
return;
|
|
}
|
|
setError('تبدیل صوت به متن ناموفق بود.');
|
|
};
|
|
rec.onend = () => {
|
|
if (!stopping && !failed && recording.value) {
|
|
try {
|
|
rec.start();
|
|
return;
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
stopTimer();
|
|
recognition = null;
|
|
setRecording(false);
|
|
};
|
|
|
|
recognition = rec;
|
|
stopping = false;
|
|
failed = false;
|
|
seconds.value = 0;
|
|
try {
|
|
rec.start();
|
|
} catch {
|
|
setError(UNSUPPORTED);
|
|
recognition = null;
|
|
return;
|
|
}
|
|
setRecording(true);
|
|
timer = window.setInterval(() => {
|
|
seconds.value += 1;
|
|
}, 1000);
|
|
limitTimer = window.setTimeout(() => stop(), MAX_SECONDS * 1000);
|
|
}
|
|
|
|
function toggle() {
|
|
if (props.disabled) return;
|
|
if (recording.value) {
|
|
stop();
|
|
return;
|
|
}
|
|
start();
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
stop();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<a-button
|
|
:type="recording ? 'primary' : 'default'"
|
|
:danger="recording"
|
|
:disabled="disabled"
|
|
:aria-label="ariaLabel"
|
|
:title="ariaLabel"
|
|
@click="toggle"
|
|
>
|
|
<template #icon>
|
|
<PauseCircleOutlined v-if="recording" />
|
|
<AudioOutlined v-else />
|
|
</template>
|
|
<span v-if="recording">{{ timerLabel }}</span>
|
|
</a-button>
|
|
<div v-if="error && !hideError">
|
|
<a-typography-text type="danger">{{ error }}</a-typography-text>
|
|
</div>
|
|
</div>
|
|
</template>
|