mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: read a message aloud with the browser's voice, highlighting what it reads
This commit is contained in:
@@ -11,6 +11,8 @@ import { htmlToReadableText } from "@/lib/html-to-readable-text";
|
||||
import { pauseAnimatedImages } from "@/lib/animated-images";
|
||||
import { applyMailTextSize, mailFitOptions } from "@/lib/mail-reading";
|
||||
import { useMailReading } from "@/hooks/use-mail-reading";
|
||||
import { useReadAloud } from "@/hooks/use-read-aloud";
|
||||
import { ReadAloudBar } from "./read-aloud-bar";
|
||||
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
|
||||
@@ -105,6 +107,7 @@ import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Type,
|
||||
Volume2,
|
||||
ArrowLeft,
|
||||
Pencil,
|
||||
} from "@/components/icons";
|
||||
@@ -3333,6 +3336,12 @@ export function EmailViewer({
|
||||
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
|
||||
// The plain-text body, where reader-above-quote mounts between text and quote.
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
// Read aloud (browser speech): the body as shown, HTML frame or plain text.
|
||||
const getReadAloudRoot = useCallback(
|
||||
() => (effectiveEmailContent.isHtml ? iframeRef.current?.contentDocument?.body ?? null : plainBodyEl),
|
||||
[effectiveEmailContent.isHtml, plainBodyEl],
|
||||
);
|
||||
const readAloud = useReadAloud({ getRoot: getReadAloudRoot, lang: bodyLanguage, resetKey: effectiveEmailContent.isHtml ? emailIframeSrcDoc : effectiveEmailContent.html });
|
||||
// A plain-text body sits in the page: watch the page's selection for it.
|
||||
useEffect(() => {
|
||||
if (!plainBodyEl || !hasDetailSidebar) return;
|
||||
@@ -4156,6 +4165,9 @@ export function EmailViewer({
|
||||
// The reader's own entries after the message's: the view toggle, Import
|
||||
// (into the open folder) and, with a keyboard, the shortcuts.
|
||||
const readerExtras: MessageActionEntry[] = [
|
||||
...(readAloud.supported && !isBodyLoading
|
||||
? [{ kind: 'item' as const, key: 'extra:read_aloud' as const, icon: Volume2, label: t('read_aloud'), run: readAloud.start }]
|
||||
: []),
|
||||
...(hasHtmlBody
|
||||
? [{ kind: 'item' as const, key: 'extra:plain_text' as const, icon: plainView ? Type : FileText, label: plainView ? t('show_formatted') : t('show_as_plain_text'), run: () => setPlainViewOverride(!plainView) }]
|
||||
: []),
|
||||
@@ -6219,6 +6231,7 @@ export function EmailViewer({
|
||||
!isDark && resolvedTheme === 'dark' ? "bg-white email-content-light" : "bg-background"
|
||||
)}
|
||||
style={isDark ? { backgroundColor: '#121212' } : undefined}>
|
||||
<ReadAloudBar state={readAloud.state} onPause={readAloud.pause} onResume={readAloud.resume} onStop={readAloud.stop} />
|
||||
{!isBodyLoading && plainView && hasHtmlBody && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 px-6 pt-3 text-sm text-muted-foreground" data-testid="plain-view-note">
|
||||
<span>{t('shown_as_plain_text')}</span>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Pause, Play, Stop, Volume2 } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReadAloudState } from "@/hooks/use-read-aloud";
|
||||
|
||||
interface ReadAloudBarProps {
|
||||
state: ReadAloudState;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onStop: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Pause, resume and stop while a message is read aloud. */
|
||||
export function ReadAloudBar({ state, onPause, onResume, onStop, className }: ReadAloudBarProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
if (state === 'idle') return null;
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={t('read_aloud')}
|
||||
className={cn("flex flex-wrap items-center gap-2 px-4 py-2 border-b border-border bg-muted/60 text-sm", className)}
|
||||
data-testid="read-aloud-bar"
|
||||
>
|
||||
<Volume2 className="w-4 h-4 text-muted-foreground shrink-0" aria-hidden="true" />
|
||||
<span className="me-auto text-foreground" role="status">
|
||||
{state === 'paused' ? t('read_aloud_paused') : t('read_aloud_reading')}
|
||||
</span>
|
||||
{state === 'paused' ? (
|
||||
<Button variant="outline" size="xs" onClick={onResume}>
|
||||
<Play className="w-4 h-4 me-1" aria-hidden="true" />
|
||||
{t('read_aloud_resume')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="xs" onClick={onPause}>
|
||||
<Pause className="w-4 h-4 me-1" aria-hidden="true" />
|
||||
{t('read_aloud_pause')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="xs" onClick={onStop}>
|
||||
<Stop className="w-4 h-4 me-1" aria-hidden="true" />
|
||||
{t('read_aloud_stop')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -171,6 +171,8 @@ export {
|
||||
IconLayoutSidebarRightCollapse as PanelRightClose,
|
||||
IconLayoutSidebarRightExpand as PanelRightOpen,
|
||||
IconPaperclip as Paperclip,
|
||||
IconPlayerPause as Pause,
|
||||
IconPlayerStop as Stop,
|
||||
IconPencil as PenLine,
|
||||
IconEdit as PenSquare,
|
||||
IconVector as PenTool,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { presetChanges } from '@/lib/accessibility-presets';
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns?: string) => (key: string, values?: Record<string, unknown>) =>
|
||||
`${ns ? `${ns}.` : ''}${key}${values ? ` ${JSON.stringify(values)}` : ''}`,
|
||||
useLocale: () => 'de',
|
||||
}));
|
||||
|
||||
const settings = () => useSettingsStore.getState();
|
||||
@@ -99,3 +100,22 @@ describe('toast duration', () => {
|
||||
expect(values).toEqual(['20', '60', '0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read aloud voice', () => {
|
||||
const voice = (name: string, lang: string) => ({ name, lang, voiceURI: `uri:${name}`, default: false, localService: true });
|
||||
|
||||
it('offers the browser default and the voices for the interface language', () => {
|
||||
const voices = [voice('Anna', 'de-DE'), voice('Daniel', 'en-GB'), voice('Markus', 'de-AT')];
|
||||
vi.stubGlobal('SpeechSynthesisUtterance', class {});
|
||||
vi.stubGlobal('speechSynthesis', { getVoices: () => voices, addEventListener() {}, removeEventListener() {} });
|
||||
try {
|
||||
render(<AccessibilitySettings />);
|
||||
const select = screen.getByRole('combobox', { name: 'settings.accessibility.read_aloud_voice.label' }) as HTMLSelectElement;
|
||||
expect(Array.from(select.options).map((o) => o.textContent)).toEqual(['settings.accessibility.read_aloud_voice.default', 'Anna', 'Markus']);
|
||||
fireEvent.change(select, { target: { value: 'uri:Markus' } });
|
||||
expect(settings().readAloudVoice).toBe('uri:Markus');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, Select, ToggleSwitch } from './settings-section';
|
||||
@@ -10,6 +10,8 @@ import { ChevronRight, Keyboard, Volume2 } from '@/components/icons';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
|
||||
import { openKeyboardShortcuts } from '@/lib/app-commands';
|
||||
import { voicesForLocale } from '@/lib/read-aloud';
|
||||
import { useSpeechVoices } from '@/hooks/use-read-aloud';
|
||||
import { NOTIFICATION_SOUNDS, playNotificationSound, type NotificationSoundChoice } from '@/lib/notification-sound';
|
||||
import {
|
||||
ACCESSIBILITY_DEFAULTS,
|
||||
@@ -66,6 +68,8 @@ export function AccessibilitySettings() {
|
||||
const faviconsAllowed = !useNoThirdPartyTraffic();
|
||||
const [applied, setApplied] = useState<{ id: AccessibilityPresetId; changes: PresetChange[] } | null>(null);
|
||||
const [resetDone, setResetDone] = useState(false);
|
||||
const locale = useLocale();
|
||||
const speechVoices = useSpeechVoices();
|
||||
|
||||
const toastMin = getRestriction('toastDuration')?.min;
|
||||
const allowsValue = (key: string, value: unknown) =>
|
||||
@@ -108,6 +112,17 @@ export function AccessibilitySettings() {
|
||||
/>
|
||||
);
|
||||
|
||||
// Voices for the interface language, plus the one already chosen even if
|
||||
// this browser doesn't have it (another device may).
|
||||
const localeVoices = voicesForLocale(speechVoices, locale);
|
||||
const readAloudVoiceOptions = [
|
||||
{ value: '', label: t('read_aloud_voice.default') },
|
||||
...localeVoices.map((voice) => ({ value: voice.voiceURI, label: voice.name })),
|
||||
...(settings.readAloudVoice && !localeVoices.some((voice) => voice.voiceURI === settings.readAloudVoice)
|
||||
? [{ value: settings.readAloudVoice, label: speechVoices.find((voice) => voice.voiceURI === settings.readAloudVoice)?.name ?? settings.readAloudVoice }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const toastOptions = TOAST_DURATIONS
|
||||
.filter((value) => allowsValue('toastDuration', value))
|
||||
.map((value) => ({
|
||||
@@ -426,6 +441,13 @@ export function AccessibilitySettings() {
|
||||
options={READ_ALOUD_RATES.map((rate) => ({ value: String(rate), label: `${rate}×` }))}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t('read_aloud_voice.label')} description={t('read_aloud_voice.description')}>
|
||||
<Select
|
||||
value={settings.readAloudVoice}
|
||||
onChange={(value) => set('readAloudVoice', value)}
|
||||
options={readAloudVoiceOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t('compose_font.label')} description={t('compose_font.description')}>
|
||||
<Select
|
||||
value={settings.composeFontFamily}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useReadAloud } from '@/hooks/use-read-aloud';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
class FakeUtterance {
|
||||
text: string;
|
||||
rate = 1;
|
||||
lang = '';
|
||||
voice: unknown = null;
|
||||
onstart: (() => void) | null = null;
|
||||
onend: (() => void) | null = null;
|
||||
onerror: ((e: { error: string }) => void) | null = null;
|
||||
onboundary: ((e: { name: string; charIndex: number; charLength?: number }) => void) | null = null;
|
||||
constructor(text: string) {
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
const spoken: FakeUtterance[] = [];
|
||||
const synth = {
|
||||
speak: vi.fn((u: FakeUtterance) => { spoken.push(u); }),
|
||||
cancel: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
getVoices: () => [{ name: 'Anna', lang: 'de-DE', voiceURI: 'uri:anna' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
spoken.length = 0;
|
||||
vi.stubGlobal('SpeechSynthesisUtterance', FakeUtterance);
|
||||
vi.stubGlobal('speechSynthesis', synth);
|
||||
useSettingsStore.setState({ readAloudRate: 1.25, readAloudVoice: 'uri:anna' } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function body(html: string) {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = html;
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('useReadAloud', () => {
|
||||
it('reads sentence by sentence at the chosen speed and voice', () => {
|
||||
const el = body('<p>First sentence. Second one.</p>');
|
||||
const { result } = renderHook(() => useReadAloud({ getRoot: () => el, lang: 'de' }));
|
||||
expect(result.current.supported).toBe(true);
|
||||
act(() => result.current.start());
|
||||
expect(result.current.state).toBe('speaking');
|
||||
expect(spoken.map((u) => u.text)).toEqual(['First sentence.']);
|
||||
expect(spoken[0].rate).toBe(1.25);
|
||||
expect(spoken[0].voice).toMatchObject({ name: 'Anna' });
|
||||
act(() => spoken[0].onend!());
|
||||
expect(spoken.map((u) => u.text)).toEqual(['First sentence.', 'Second one.']);
|
||||
act(() => spoken[1].onend!());
|
||||
expect(result.current.state).toBe('idle');
|
||||
});
|
||||
|
||||
it('pauses, resumes and stops', () => {
|
||||
const el = body('<p>One. Two.</p>');
|
||||
const { result } = renderHook(() => useReadAloud({ getRoot: () => el }));
|
||||
act(() => result.current.start());
|
||||
act(() => result.current.pause());
|
||||
expect(synth.pause).toHaveBeenCalled();
|
||||
expect(result.current.state).toBe('paused');
|
||||
act(() => result.current.resume());
|
||||
expect(result.current.state).toBe('speaking');
|
||||
act(() => result.current.stop());
|
||||
expect(synth.cancel).toHaveBeenCalled();
|
||||
expect(result.current.state).toBe('idle');
|
||||
// A late end event from the cancelled run starts nothing.
|
||||
act(() => spoken[0].onend!());
|
||||
expect(spoken).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stops when another message opens', () => {
|
||||
const el = body('<p>One. Two.</p>');
|
||||
const { result, rerender } = renderHook(({ id }) => useReadAloud({ getRoot: () => el, resetKey: id }), { initialProps: { id: 'a' } });
|
||||
act(() => result.current.start());
|
||||
synth.cancel.mockClear();
|
||||
rerender({ id: 'b' });
|
||||
expect(synth.cancel).toHaveBeenCalled();
|
||||
expect(result.current.state).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import {
|
||||
SENTENCE_HIGHLIGHT,
|
||||
WORD_HIGHLIGHT,
|
||||
collectSpeechText,
|
||||
pickVoice,
|
||||
rangeForOffsets,
|
||||
setSpeechHighlight,
|
||||
splitSentences,
|
||||
wordEnd,
|
||||
type SpeechText,
|
||||
} from "@/lib/read-aloud";
|
||||
|
||||
export type ReadAloudState = 'idle' | 'speaking' | 'paused';
|
||||
|
||||
interface Options {
|
||||
/** The element whose text is read; looked up when reading starts. */
|
||||
getRoot: () => Element | null;
|
||||
/** The text's language (BCP 47), when known; otherwise the voice decides. */
|
||||
lang?: string | null;
|
||||
/** Reading stops when this changes (another message opened). */
|
||||
resetKey?: unknown;
|
||||
}
|
||||
|
||||
function speech(): SpeechSynthesis | null {
|
||||
return typeof window !== 'undefined' && 'speechSynthesis' in window && typeof SpeechSynthesisUtterance !== 'undefined'
|
||||
? window.speechSynthesis
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a message aloud with the browser's speech synthesis, at the speed
|
||||
* and in the voice from Settings > Accessibility, one sentence per utterance.
|
||||
* The sentence being read is highlighted, and the word too where the browser
|
||||
* sends word boundary events.
|
||||
*/
|
||||
export function useReadAloud({ getRoot, lang, resetKey }: Options) {
|
||||
const rate = useSettingsStore((state) => state.readAloudRate);
|
||||
const voiceName = useSettingsStore((state) => state.readAloudVoice);
|
||||
const [state, setState] = useState<ReadAloudState>('idle');
|
||||
const supported = speech() !== null;
|
||||
// Bumped by every start and stop, so callbacks of an old run do nothing.
|
||||
const runRef = useRef(0);
|
||||
const docRef = useRef<Document | null>(null);
|
||||
|
||||
const clearHighlights = useCallback(() => {
|
||||
const doc = docRef.current;
|
||||
if (!doc) return;
|
||||
setSpeechHighlight(doc, SENTENCE_HIGHLIGHT, null);
|
||||
setSpeechHighlight(doc, WORD_HIGHLIGHT, null);
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
runRef.current += 1;
|
||||
speech()?.cancel();
|
||||
clearHighlights();
|
||||
setState('idle');
|
||||
}, [clearHighlights]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
const synth = speech();
|
||||
const root = getRoot();
|
||||
if (!synth || !root) return;
|
||||
synth.cancel();
|
||||
clearHighlights();
|
||||
const run = ++runRef.current;
|
||||
const doc = root.ownerDocument;
|
||||
docRef.current = doc;
|
||||
const text: SpeechText = collectSpeechText(root);
|
||||
const chunks = splitSentences(text.text, lang ?? undefined);
|
||||
if (chunks.length === 0) {
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
const voice = pickVoice(synth.getVoices(), voiceName);
|
||||
|
||||
const speakChunk = (index: number) => {
|
||||
if (run !== runRef.current) return;
|
||||
if (index >= chunks.length) {
|
||||
clearHighlights();
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
const chunk = chunks[index];
|
||||
const utterance = new SpeechSynthesisUtterance(text.text.slice(chunk.start, chunk.end));
|
||||
utterance.rate = rate;
|
||||
if (voice) {
|
||||
utterance.voice = voice;
|
||||
utterance.lang = voice.lang;
|
||||
} else if (lang) {
|
||||
utterance.lang = lang;
|
||||
}
|
||||
utterance.onstart = () => {
|
||||
if (run !== runRef.current) return;
|
||||
const range = rangeForOffsets(text, chunk.start, chunk.end);
|
||||
setSpeechHighlight(doc, SENTENCE_HIGHLIGHT, range);
|
||||
setSpeechHighlight(doc, WORD_HIGHLIGHT, null);
|
||||
// Keep the sentence on screen without animating the scroll.
|
||||
const anchor = range?.startContainer.parentElement;
|
||||
anchor?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
};
|
||||
utterance.onboundary = (event) => {
|
||||
if (run !== runRef.current || event.name !== 'word') return;
|
||||
const from = chunk.start + event.charIndex;
|
||||
const to = event.charLength ? from + event.charLength : wordEnd(text.text, from);
|
||||
setSpeechHighlight(doc, WORD_HIGHLIGHT, rangeForOffsets(text, from, to));
|
||||
};
|
||||
utterance.onend = () => speakChunk(index + 1);
|
||||
utterance.onerror = (event) => {
|
||||
// "interrupted"/"canceled" come from our own stop.
|
||||
if (run !== runRef.current) return;
|
||||
if (event.error === 'interrupted' || event.error === 'canceled') return;
|
||||
stop();
|
||||
};
|
||||
synth.speak(utterance);
|
||||
};
|
||||
setState('speaking');
|
||||
speakChunk(0);
|
||||
}, [getRoot, lang, rate, voiceName, clearHighlights, stop]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
speech()?.pause();
|
||||
setState('paused');
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
speech()?.resume();
|
||||
setState('speaking');
|
||||
}, []);
|
||||
|
||||
// Another message, or the reader closed: stop talking.
|
||||
useEffect(() => stop, [resetKey, stop]);
|
||||
|
||||
return { supported, state, start, pause, resume, stop };
|
||||
}
|
||||
|
||||
/** The browser's speech voices, updated when the browser finishes loading them. */
|
||||
export function useSpeechVoices(): SpeechSynthesisVoice[] {
|
||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>(() => speech()?.getVoices() ?? []);
|
||||
useEffect(() => {
|
||||
const synth = speech();
|
||||
if (!synth) return;
|
||||
const update = () => setVoices(synth.getVoices());
|
||||
update();
|
||||
synth.addEventListener?.('voiceschanged', update);
|
||||
return () => synth.removeEventListener?.('voiceschanged', update);
|
||||
}, []);
|
||||
return voices;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collectSpeechText, pickVoice, rangeForOffsets, splitSentences, voicesForLocale, wordEnd } from '@/lib/read-aloud';
|
||||
|
||||
function root(html: string): HTMLElement {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = html;
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('collectSpeechText', () => {
|
||||
it('reads what is on screen, block by block, with alt text', () => {
|
||||
const el = root('<p>Hello <b>Ann</b>.</p><p>See <img alt="the chart"> below.</p><div style="display:none">hidden</div><button>Play</button>');
|
||||
expect(collectSpeechText(el).text).toBe('Hello Ann.\nSee the chart below.\n');
|
||||
});
|
||||
|
||||
it('reads only the summary of a folded quote', () => {
|
||||
const el = root('<p>Reply</p><details><summary>Show quoted text</summary><p>Old mail</p></details>');
|
||||
expect(collectSpeechText(el).text).not.toContain('Old mail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitSentences', () => {
|
||||
it('splits sentences and lines, dropping empty ones', () => {
|
||||
const text = 'Hi Ann. How are you?\n\nBest,\nBob';
|
||||
expect(splitSentences(text, 'en').map(({ start, end }) => text.slice(start, end))).toEqual(['Hi Ann.', 'How are you?', 'Best,', 'Bob']);
|
||||
});
|
||||
|
||||
it('cuts long sentences so no utterance gets too long', () => {
|
||||
const text = `${'word '.repeat(120)}end.`;
|
||||
const chunks = splitSentences(text, 'en');
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every(({ start, end }) => end - start <= 220)).toBe(true);
|
||||
expect(chunks.map(({ start, end }) => text.slice(start, end)).join(' ')).toBe(text.trim());
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeForOffsets', () => {
|
||||
it('maps spoken offsets back to the text nodes', () => {
|
||||
const el = root('<p>Hello <b>dear</b> Ann</p>');
|
||||
const speech = collectSpeechText(el);
|
||||
const start = speech.text.indexOf('dear');
|
||||
const range = rangeForOffsets(speech, start, start + 8)!;
|
||||
expect(range.toString()).toBe('dear Ann');
|
||||
});
|
||||
|
||||
it('is null for text that is not on the page', () => {
|
||||
const el = root('<img alt="logo">');
|
||||
const speech = collectSpeechText(el);
|
||||
expect(rangeForOffsets(speech, 0, speech.text.length)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('voices', () => {
|
||||
const voice = (name: string, lang: string) => ({ name, lang, voiceURI: `uri:${name}` }) as SpeechSynthesisVoice;
|
||||
const voices = [voice('Zoe', 'en-US'), voice('Anna', 'de-DE'), voice('Max', 'de_AT')];
|
||||
|
||||
it('lists the voices for the interface language', () => {
|
||||
expect(voicesForLocale(voices, 'de').map((v) => v.name)).toEqual(['Anna', 'Max']);
|
||||
});
|
||||
|
||||
it('picks the chosen voice, or none for the browser default', () => {
|
||||
expect(pickVoice(voices, 'uri:Max')?.name).toBe('Max');
|
||||
expect(pickVoice(voices, 'Zoe')?.name).toBe('Zoe');
|
||||
expect(pickVoice(voices, '')).toBeNull();
|
||||
expect(pickVoice(voices, 'uri:gone')).toBeNull();
|
||||
});
|
||||
|
||||
it('finds the end of a word', () => {
|
||||
expect(wordEnd('say hello world', 4)).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Read aloud: the text of a message body, split into sentences for the
|
||||
* browser's speech synthesis, with a map back to the DOM so the sentence and
|
||||
* word being spoken can be highlighted.
|
||||
*
|
||||
* Runs entirely in the browser (Web Speech API), so it works in Lite and
|
||||
* without a server.
|
||||
*/
|
||||
|
||||
/** Where a piece of the spoken text came from; `node` is null for text we added (line breaks, alt text). */
|
||||
export interface SpeechSegment {
|
||||
node: Text | null;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SpeechText {
|
||||
text: string;
|
||||
segments: SpeechSegment[];
|
||||
}
|
||||
|
||||
const BLOCK = /^(ADDRESS|ARTICLE|ASIDE|BLOCKQUOTE|DD|DIV|DL|DT|FIGCAPTION|FIGURE|FOOTER|H[1-6]|HEADER|HR|LI|MAIN|NAV|OL|P|PRE|SECTION|TABLE|TR|UL|TD|TH)$/;
|
||||
const SKIP = /^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT|BUTTON|SVG|CANVAS|SELECT|TEXTAREA)$/;
|
||||
|
||||
/**
|
||||
* The text of `root` as it reads on screen: hidden parts left out, a line
|
||||
* break after each block, image alt text in place of the image.
|
||||
*/
|
||||
export function collectSpeechText(root: Element): SpeechText {
|
||||
const view = root.ownerDocument.defaultView;
|
||||
const segments: SpeechSegment[] = [];
|
||||
let text = '';
|
||||
const add = (value: string, node: Text | null) => {
|
||||
if (!value) return;
|
||||
segments.push({ node, start: text.length, end: text.length + value.length });
|
||||
text += value;
|
||||
};
|
||||
const breakLine = () => {
|
||||
if (text && !text.endsWith('\n')) add('\n', null);
|
||||
};
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === 3) {
|
||||
add(node.nodeValue ?? '', node as Text);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1) return;
|
||||
const el = node as Element;
|
||||
const tag = el.tagName.toUpperCase();
|
||||
if (SKIP.test(tag) || el.getAttribute('aria-hidden') === 'true') return;
|
||||
if (view) {
|
||||
const style = view.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return;
|
||||
}
|
||||
// A closed <details> (the folded quote) shows only its summary.
|
||||
if (tag === 'DETAILS' && !(el as HTMLDetailsElement).open) {
|
||||
const summary = el.querySelector(':scope > summary');
|
||||
if (summary) walk(summary);
|
||||
return;
|
||||
}
|
||||
if (tag === 'BR') {
|
||||
add('\n', null);
|
||||
return;
|
||||
}
|
||||
if (tag === 'IMG') {
|
||||
const alt = el.getAttribute('alt')?.trim();
|
||||
if (alt) add(` ${alt} `, null);
|
||||
return;
|
||||
}
|
||||
const block = BLOCK.test(tag);
|
||||
if (block) breakLine();
|
||||
el.childNodes.forEach(walk);
|
||||
if (block) breakLine();
|
||||
};
|
||||
walk(root);
|
||||
return { text, segments };
|
||||
}
|
||||
|
||||
export interface SpeechChunk {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Longest chunk handed to one utterance; Chrome stops long ones midway. */
|
||||
const MAX_CHUNK = 220;
|
||||
|
||||
/**
|
||||
* Sentences of `text` as offset ranges, skipping blank ones. Long sentences
|
||||
* are cut at a comma or space so no utterance gets too long.
|
||||
*/
|
||||
export function splitSentences(text: string, locale?: string): SpeechChunk[] {
|
||||
const raw: SpeechChunk[] = [];
|
||||
const Segmenter = (Intl as unknown as { Segmenter?: new (l?: string, o?: { granularity: string }) => { segment(t: string): Iterable<{ index: number; segment: string }> } }).Segmenter;
|
||||
if (Segmenter) {
|
||||
for (const { index, segment } of new Segmenter(locale, { granularity: 'sentence' }).segment(text)) {
|
||||
raw.push({ start: index, end: index + segment.length });
|
||||
}
|
||||
} else {
|
||||
const re = /[^.!?\n]+(?:[.!?]+|\n|$)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text))) {
|
||||
if (!m[0]) break;
|
||||
raw.push({ start: m.index, end: m.index + m[0].length });
|
||||
}
|
||||
}
|
||||
// Line breaks end a sentence too (headings, list items, table rows).
|
||||
const lines: SpeechChunk[] = [];
|
||||
for (const chunk of raw) {
|
||||
let start = chunk.start;
|
||||
for (let i = chunk.start; i < chunk.end; i++) {
|
||||
if (text[i] === '\n') {
|
||||
lines.push({ start, end: i });
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
lines.push({ start, end: chunk.end });
|
||||
}
|
||||
const out: SpeechChunk[] = [];
|
||||
for (const chunk of lines) {
|
||||
let { start } = chunk;
|
||||
while (chunk.end - start > MAX_CHUNK) {
|
||||
const window = text.slice(start, start + MAX_CHUNK);
|
||||
const cut = Math.max(window.lastIndexOf(', '), window.lastIndexOf('; '));
|
||||
const at = cut > MAX_CHUNK / 3 ? cut + 1 : Math.max(window.lastIndexOf(' '), MAX_CHUNK / 2);
|
||||
out.push({ start, end: start + at });
|
||||
start += at;
|
||||
}
|
||||
out.push({ start, end: chunk.end });
|
||||
}
|
||||
// Trim whitespace and drop chunks with nothing to say.
|
||||
return out
|
||||
.map(({ start, end }) => {
|
||||
while (start < end && /\s/.test(text[start])) start++;
|
||||
while (end > start && /\s/.test(text[end - 1])) end--;
|
||||
return { start, end };
|
||||
})
|
||||
.filter(({ start, end }) => end > start && /[\p{L}\p{N}]/u.test(text.slice(start, end)));
|
||||
}
|
||||
|
||||
/** A DOM range over the text between two offsets, or null if none of it is page text. */
|
||||
export function rangeForOffsets(speech: SpeechText, start: number, end: number): Range | null {
|
||||
let first: { node: Text; offset: number } | null = null;
|
||||
let last: { node: Text; offset: number } | null = null;
|
||||
for (const seg of speech.segments) {
|
||||
if (!seg.node || seg.end <= start || seg.start >= end) continue;
|
||||
if (!first) first = { node: seg.node, offset: Math.max(0, start - seg.start) };
|
||||
last = { node: seg.node, offset: Math.min(seg.end, end) - seg.start };
|
||||
}
|
||||
if (!first || !last) return null;
|
||||
const range = first.node.ownerDocument.createRange();
|
||||
try {
|
||||
range.setStart(first.node, first.offset);
|
||||
range.setEnd(last.node, last.offset);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
/** End of the word starting at `index`, for browsers whose boundary events carry no length. */
|
||||
export function wordEnd(text: string, index: number): number {
|
||||
const match = /^[^\s]+/.exec(text.slice(index));
|
||||
return index + (match ? match[0].length : 0);
|
||||
}
|
||||
|
||||
export const SENTENCE_HIGHLIGHT = 'bulwark-read-aloud-sentence';
|
||||
export const WORD_HIGHLIGHT = 'bulwark-read-aloud-word';
|
||||
const HIGHLIGHT_STYLE = `::highlight(${SENTENCE_HIGHLIGHT}){background-color:rgba(255,213,0,.35);color:inherit}::highlight(${WORD_HIGHLIGHT}){background-color:#ffd500;color:#000}`;
|
||||
|
||||
interface HighlightWindow {
|
||||
CSS?: { highlights?: Map<string, unknown> };
|
||||
Highlight?: new (...ranges: Range[]) => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks `range` with a CSS custom highlight in its own document (a message
|
||||
* frame has its own registry). Does nothing where the browser has no
|
||||
* Highlight API; null clears it.
|
||||
*/
|
||||
export function setSpeechHighlight(doc: Document, name: string, range: Range | null): void {
|
||||
const win = doc.defaultView as unknown as HighlightWindow | null;
|
||||
const registry = win?.CSS?.highlights;
|
||||
if (!registry || !win?.Highlight) return;
|
||||
if (!doc.head.querySelector('style[data-bulwark-read-aloud]')) {
|
||||
const style = doc.createElement('style');
|
||||
style.setAttribute('data-bulwark-read-aloud', '');
|
||||
style.textContent = HIGHLIGHT_STYLE;
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
if (range) registry.set(name, new win.Highlight(range));
|
||||
else registry.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The voice for `wanted` (a voiceURI or name) among the browser's voices, or
|
||||
* null for the browser default.
|
||||
*/
|
||||
export function pickVoice(voices: readonly SpeechSynthesisVoice[], wanted: string): SpeechSynthesisVoice | null {
|
||||
if (!wanted) return null;
|
||||
return voices.find((v) => v.voiceURI === wanted) ?? voices.find((v) => v.name === wanted) ?? null;
|
||||
}
|
||||
|
||||
/** The browser's voices that speak `locale` ("de" matches "de-DE", "de-AT"). */
|
||||
export function voicesForLocale(voices: readonly SpeechSynthesisVoice[], locale: string): SpeechSynthesisVoice[] {
|
||||
const primary = locale.toLowerCase().split(/[-_]/)[0];
|
||||
return voices
|
||||
.filter((v) => v.lang.toLowerCase().replace('_', '-').split('-')[0] === primary)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "العرض منسقًا",
|
||||
"shown_as_plain_text": "تُعرض هذه الرسالة كنص عادي.",
|
||||
"play_animation": "تشغيل الرسوم المتحركة",
|
||||
"pause_animation": "إيقاف الرسوم المتحركة مؤقتًا"
|
||||
"pause_animation": "إيقاف الرسوم المتحركة مؤقتًا",
|
||||
"read_aloud": "القراءة بصوت عالٍ",
|
||||
"read_aloud_reading": "تجري القراءة بصوت عالٍ",
|
||||
"read_aloud_paused": "القراءة متوقفة مؤقتًا",
|
||||
"read_aloud_pause": "إيقاف مؤقت",
|
||||
"read_aloud_resume": "استئناف",
|
||||
"read_aloud_stop": "إيقاف القراءة"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "خارجي",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "يعيد كل إعدادات هذه الصفحة إلى قيمتها الافتراضية، أو إلى القيمة التي حددتها مؤسستك. الإعدادات المشتركة مثل عرض المحادثات تبقى كما هي.",
|
||||
"button": "إعادة تعيين",
|
||||
"done": "تمت إعادة تعيين إعدادات إمكانية الوصول."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "صوت القراءة بصوت عالٍ",
|
||||
"description": "الصوت الذي يقرأ الرسائل بصوت عالٍ. تعرض القائمة الأصوات المتوفرة في متصفحك للغة الواجهة.",
|
||||
"default": "الافتراضي في المتصفح"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Mostra amb format",
|
||||
"shown_as_plain_text": "Aquest missatge es mostra com a text sense format.",
|
||||
"play_animation": "Reprodueix l'animació",
|
||||
"pause_animation": "Posa en pausa l'animació"
|
||||
"pause_animation": "Posa en pausa l'animació",
|
||||
"read_aloud": "Llegeix en veu alta",
|
||||
"read_aloud_reading": "S'està llegint en veu alta",
|
||||
"read_aloud_paused": "Lectura en pausa",
|
||||
"read_aloud_pause": "Pausa",
|
||||
"read_aloud_resume": "Reprèn",
|
||||
"read_aloud_stop": "Atura la lectura"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Extern",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Torna totes les opcions d'aquesta pàgina al valor per defecte, o al que hagi fixat la teva organització. Les opcions compartides, com la vista de converses, no canvien.",
|
||||
"button": "Restableix",
|
||||
"done": "S'han restablert les opcions d'accessibilitat."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Veu de la lectura en veu alta",
|
||||
"description": "La veu que llegeix els missatges. La llista mostra les veus que té el navegador per a la llengua de la interfície.",
|
||||
"default": "Predeterminada del navegador"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Zobrazit formátovaně",
|
||||
"shown_as_plain_text": "Tato zpráva se zobrazuje jako prostý text.",
|
||||
"play_animation": "Přehrát animaci",
|
||||
"pause_animation": "Pozastavit animaci"
|
||||
"pause_animation": "Pozastavit animaci",
|
||||
"read_aloud": "Předčítat",
|
||||
"read_aloud_reading": "Předčítá se",
|
||||
"read_aloud_paused": "Předčítání pozastaveno",
|
||||
"read_aloud_pause": "Pozastavit",
|
||||
"read_aloud_resume": "Pokračovat",
|
||||
"read_aloud_stop": "Ukončit předčítání"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Externí",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Vrátí všechna nastavení na této stránce na výchozí hodnoty nebo na hodnoty, které nastavila vaše organizace. Sdílená nastavení, například zobrazení konverzací, zůstanou beze změny.",
|
||||
"button": "Obnovit",
|
||||
"done": "Nastavení přístupnosti bylo obnoveno."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Hlas předčítání",
|
||||
"description": "Hlas, který předčítá zprávy. Seznam ukazuje hlasy, které má prohlížeč pro jazyk rozhraní.",
|
||||
"default": "Výchozí v prohlížeči"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Vis formateret",
|
||||
"shown_as_plain_text": "Denne besked vises som almindelig tekst.",
|
||||
"play_animation": "Afspil animation",
|
||||
"pause_animation": "Sæt animation på pause"
|
||||
"pause_animation": "Sæt animation på pause",
|
||||
"read_aloud": "Læs op",
|
||||
"read_aloud_reading": "Læser op",
|
||||
"read_aloud_paused": "Oplæsning sat på pause",
|
||||
"read_aloud_pause": "Pause",
|
||||
"read_aloud_resume": "Fortsæt",
|
||||
"read_aloud_stop": "Stop oplæsning"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Ekstern",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Sætter alle indstillinger på denne side tilbage til standard eller til det, din organisation har valgt. Delte indstillinger som samtalevisning forbliver, som de er.",
|
||||
"button": "Nulstil",
|
||||
"done": "Tilgængelighedsindstillingerne er nulstillet."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Oplæsningsstemme",
|
||||
"description": "Stemmen, der læser beskeder op. Listen viser de stemmer, din browser har til grænsefladens sprog.",
|
||||
"default": "Browserens standard"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Formatiert anzeigen",
|
||||
"shown_as_plain_text": "Diese Nachricht wird als reiner Text angezeigt.",
|
||||
"play_animation": "Animation abspielen",
|
||||
"pause_animation": "Animation anhalten"
|
||||
"pause_animation": "Animation anhalten",
|
||||
"read_aloud": "Vorlesen",
|
||||
"read_aloud_reading": "Wird vorgelesen",
|
||||
"read_aloud_paused": "Vorlesen angehalten",
|
||||
"read_aloud_pause": "Anhalten",
|
||||
"read_aloud_resume": "Fortsetzen",
|
||||
"read_aloud_stop": "Vorlesen beenden"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Extern",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Setzt alle Einstellungen auf dieser Seite auf den Standard zurück, oder auf den Wert, den Ihre Organisation festgelegt hat. Gemeinsame Einstellungen wie die Konversationsansicht bleiben, wie sie sind.",
|
||||
"button": "Zurücksetzen",
|
||||
"done": "Barrierefreiheit zurückgesetzt."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Vorlesestimme",
|
||||
"description": "Die Stimme, die Nachrichten vorliest. Die Liste zeigt die Stimmen, die Ihr Browser für die Sprache der Oberfläche hat.",
|
||||
"default": "Standard des Browsers"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Show formatted",
|
||||
"shown_as_plain_text": "This message is shown as plain text.",
|
||||
"play_animation": "Play animation",
|
||||
"pause_animation": "Pause animation"
|
||||
"pause_animation": "Pause animation",
|
||||
"read_aloud": "Read aloud",
|
||||
"read_aloud_reading": "Reading aloud",
|
||||
"read_aloud_paused": "Reading paused",
|
||||
"read_aloud_pause": "Pause",
|
||||
"read_aloud_resume": "Resume",
|
||||
"read_aloud_stop": "Stop reading"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "External",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Puts every setting on this page back to its default, or to the one your organisation set. Shared settings such as conversation view stay as they are.",
|
||||
"button": "Reset",
|
||||
"done": "Accessibility settings reset."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Read aloud voice",
|
||||
"description": "The voice that reads messages aloud. The list shows the voices your browser has for the interface language.",
|
||||
"default": "Browser default"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Mostrar con formato",
|
||||
"shown_as_plain_text": "Este mensaje se muestra como texto sin formato.",
|
||||
"play_animation": "Reproducir animación",
|
||||
"pause_animation": "Pausar animación"
|
||||
"pause_animation": "Pausar animación",
|
||||
"read_aloud": "Leer en voz alta",
|
||||
"read_aloud_reading": "Leyendo en voz alta",
|
||||
"read_aloud_paused": "Lectura en pausa",
|
||||
"read_aloud_pause": "Pausar",
|
||||
"read_aloud_resume": "Reanudar",
|
||||
"read_aloud_stop": "Detener la lectura"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Externo",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Devuelve todos los ajustes de esta página a su valor predeterminado, o al que haya fijado tu organización. Los ajustes compartidos, como la vista de conversaciones, no cambian.",
|
||||
"button": "Restablecer",
|
||||
"done": "Ajustes de accesibilidad restablecidos."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Voz de lectura en voz alta",
|
||||
"description": "La voz que lee los mensajes en voz alta. La lista muestra las voces que tiene tu navegador para el idioma de la interfaz.",
|
||||
"default": "Predeterminada del navegador"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Näita vormindatult",
|
||||
"shown_as_plain_text": "Seda kirja näidatakse lihttekstina.",
|
||||
"play_animation": "Esita animatsioon",
|
||||
"pause_animation": "Peata animatsioon"
|
||||
"pause_animation": "Peata animatsioon",
|
||||
"read_aloud": "Loe ette",
|
||||
"read_aloud_reading": "Loen ette",
|
||||
"read_aloud_paused": "Ettelugemine on peatatud",
|
||||
"read_aloud_pause": "Peata",
|
||||
"read_aloud_resume": "Jätka",
|
||||
"read_aloud_stop": "Lõpeta ettelugemine"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Väline",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Taastab kõik selle lehe seaded vaikeväärtustele või need, mille sinu organisatsioon on määranud. Ühised seaded, nagu vestlusvaade, jäävad samaks.",
|
||||
"button": "Lähtesta",
|
||||
"done": "Juurdepääsetavuse seaded on lähtestatud."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Ettelugemise hääl",
|
||||
"description": "Hääl, mis kirju ette loeb. Loendis on hääled, mis brauseril liidese keele jaoks on.",
|
||||
"default": "Brauseri vaikeväärtus"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "نمایش قالببندیشده",
|
||||
"shown_as_plain_text": "این پیام بهصورت متن ساده نمایش داده میشود.",
|
||||
"play_animation": "پخش پویانمایی",
|
||||
"pause_animation": "توقف پویانمایی"
|
||||
"pause_animation": "توقف پویانمایی",
|
||||
"read_aloud": "خواندن با صدا",
|
||||
"read_aloud_reading": "در حال خواندن با صدا",
|
||||
"read_aloud_paused": "خواندن متوقف شد",
|
||||
"read_aloud_pause": "توقف",
|
||||
"read_aloud_resume": "ادامه",
|
||||
"read_aloud_stop": "پایان خواندن"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "خارجی",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "همهٔ تنظیمات این صفحه را به پیشفرض یا به مقداری که سازمان شما تعیین کرده برمیگرداند. تنظیمات مشترک مانند نمای مکالمه همانطور که هستند میمانند.",
|
||||
"button": "بازنشانی",
|
||||
"done": "تنظیمات دسترسپذیری بازنشانی شد."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "صدای خواندن با صدا",
|
||||
"description": "صدایی که پیامها را میخواند. فهرست، صداهایی را نشان میدهد که مرورگر شما برای زبان رابط کاربری دارد.",
|
||||
"default": "پیشفرض مرورگر"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Afficher la mise en forme",
|
||||
"shown_as_plain_text": "Ce message est affiché en texte brut.",
|
||||
"play_animation": "Lire l'animation",
|
||||
"pause_animation": "Mettre l'animation en pause"
|
||||
"pause_animation": "Mettre l'animation en pause",
|
||||
"read_aloud": "Lire à voix haute",
|
||||
"read_aloud_reading": "Lecture à voix haute en cours",
|
||||
"read_aloud_paused": "Lecture en pause",
|
||||
"read_aloud_pause": "Pause",
|
||||
"read_aloud_resume": "Reprendre",
|
||||
"read_aloud_stop": "Arrêter la lecture"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Externe",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Remet tous les paramètres de cette page à leur valeur par défaut, ou à celle définie par votre organisation. Les paramètres partagés, comme l'affichage en conversations, restent inchangés.",
|
||||
"button": "Réinitialiser",
|
||||
"done": "Paramètres d'accessibilité réinitialisés."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Voix de lecture à voix haute",
|
||||
"description": "La voix qui lit les messages. La liste montre les voix de votre navigateur pour la langue de l'interface.",
|
||||
"default": "Voix par défaut du navigateur"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -741,7 +741,13 @@
|
||||
"show_formatted": "הצגה מעוצבת",
|
||||
"shown_as_plain_text": "הודעה זו מוצגת כטקסט פשוט.",
|
||||
"play_animation": "הפעלת האנימציה",
|
||||
"pause_animation": "השהיית האנימציה"
|
||||
"pause_animation": "השהיית האנימציה",
|
||||
"read_aloud": "הקראה",
|
||||
"read_aloud_reading": "מקריא",
|
||||
"read_aloud_paused": "ההקראה מושהית",
|
||||
"read_aloud_pause": "השהיה",
|
||||
"read_aloud_resume": "המשך",
|
||||
"read_aloud_stop": "עצירת ההקראה"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "חיצוני",
|
||||
@@ -3120,6 +3126,11 @@
|
||||
"description": "מחזיר את כל ההגדרות בעמוד זה לברירת המחדל, או לערך שהארגון שלכם קבע. הגדרות משותפות, כמו תצוגת שיחות, נשארות כמו שהן.",
|
||||
"button": "איפוס",
|
||||
"done": "הגדרות הנגישות אופסו."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "קול ההקראה",
|
||||
"description": "הקול שמקריא הודעות. הרשימה מציגה את הקולות שיש לדפדפן שלך לשפת הממשק.",
|
||||
"default": "ברירת המחדל של הדפדפן"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Megjelenítés formázva",
|
||||
"shown_as_plain_text": "Ez az üzenet egyszerű szövegként jelenik meg.",
|
||||
"play_animation": "Animáció lejátszása",
|
||||
"pause_animation": "Animáció szüneteltetése"
|
||||
"pause_animation": "Animáció szüneteltetése",
|
||||
"read_aloud": "Felolvasás",
|
||||
"read_aloud_reading": "Felolvasás folyamatban",
|
||||
"read_aloud_paused": "Felolvasás szüneteltetve",
|
||||
"read_aloud_pause": "Szünet",
|
||||
"read_aloud_resume": "Folytatás",
|
||||
"read_aloud_stop": "Felolvasás leállítása"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Külső",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Az oldal minden beállítását visszaállítja az alapértelmezettre, vagy arra, amelyet a szervezete megadott. A közös beállítások, például a beszélgetésnézet, változatlanok maradnak.",
|
||||
"button": "Visszaállítás",
|
||||
"done": "A kisegítő beállítások visszaállítva."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Felolvasási hang",
|
||||
"description": "A hang, amely felolvassa az üzeneteket. A lista a böngésző által a felület nyelvéhez kínált hangokat mutatja.",
|
||||
"default": "A böngésző alapértelmezése"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Mostra formattato",
|
||||
"shown_as_plain_text": "Questo messaggio è mostrato come testo semplice.",
|
||||
"play_animation": "Riproduci animazione",
|
||||
"pause_animation": "Metti in pausa l'animazione"
|
||||
"pause_animation": "Metti in pausa l'animazione",
|
||||
"read_aloud": "Leggi ad alta voce",
|
||||
"read_aloud_reading": "Lettura ad alta voce in corso",
|
||||
"read_aloud_paused": "Lettura in pausa",
|
||||
"read_aloud_pause": "Pausa",
|
||||
"read_aloud_resume": "Riprendi",
|
||||
"read_aloud_stop": "Interrompi la lettura"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Esterno",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Riporta ogni impostazione di questa pagina al valore predefinito, o a quello scelto dalla tua organizzazione. Le impostazioni condivise, come la vista a conversazioni, restano invariate.",
|
||||
"button": "Ripristina",
|
||||
"done": "Impostazioni di accessibilità ripristinate."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Voce della lettura ad alta voce",
|
||||
"description": "La voce che legge i messaggi. L'elenco mostra le voci che il browser ha per la lingua dell'interfaccia.",
|
||||
"default": "Predefinita del browser"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "書式付きで表示",
|
||||
"shown_as_plain_text": "このメッセージはプレーンテキストで表示されています。",
|
||||
"play_animation": "アニメーションを再生",
|
||||
"pause_animation": "アニメーションを一時停止"
|
||||
"pause_animation": "アニメーションを一時停止",
|
||||
"read_aloud": "読み上げ",
|
||||
"read_aloud_reading": "読み上げ中",
|
||||
"read_aloud_paused": "読み上げを一時停止中",
|
||||
"read_aloud_pause": "一時停止",
|
||||
"read_aloud_resume": "再開",
|
||||
"read_aloud_stop": "読み上げを停止"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "外部",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "このページのすべての設定を、既定値または組織が設定した値に戻します。会話表示などの共有設定は変わりません。",
|
||||
"button": "リセット",
|
||||
"done": "アクセシビリティ設定をリセットしました。"
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "読み上げの音声",
|
||||
"description": "メッセージを読み上げる音声です。一覧には、ブラウザーが画面の言語用に持っている音声が表示されます。",
|
||||
"default": "ブラウザーの既定"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "서식 있게 보기",
|
||||
"shown_as_plain_text": "이 메시지는 일반 텍스트로 표시됩니다.",
|
||||
"play_animation": "애니메이션 재생",
|
||||
"pause_animation": "애니메이션 일시 중지"
|
||||
"pause_animation": "애니메이션 일시 중지",
|
||||
"read_aloud": "소리 내어 읽기",
|
||||
"read_aloud_reading": "소리 내어 읽는 중",
|
||||
"read_aloud_paused": "읽기 일시 중지됨",
|
||||
"read_aloud_pause": "일시 중지",
|
||||
"read_aloud_resume": "다시 시작",
|
||||
"read_aloud_stop": "읽기 중지"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "외부",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "이 페이지의 모든 설정을 기본값 또는 조직에서 정한 값으로 되돌려요. 대화 보기처럼 공유되는 설정은 그대로 유지돼요.",
|
||||
"button": "초기화",
|
||||
"done": "접근성 설정을 초기화했어요."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "소리 내어 읽기 음성",
|
||||
"description": "메시지를 읽어 주는 음성이에요. 목록에는 브라우저에 있는 인터페이스 언어용 음성이 표시돼요.",
|
||||
"default": "브라우저 기본값"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Rādīt formatētu",
|
||||
"shown_as_plain_text": "Šis ziņojums tiek rādīts kā vienkāršs teksts.",
|
||||
"play_animation": "Atskaņot animāciju",
|
||||
"pause_animation": "Pauzēt animāciju"
|
||||
"pause_animation": "Pauzēt animāciju",
|
||||
"read_aloud": "Nolasīt",
|
||||
"read_aloud_reading": "Notiek nolasīšana",
|
||||
"read_aloud_paused": "Nolasīšana pauzēta",
|
||||
"read_aloud_pause": "Pauzēt",
|
||||
"read_aloud_resume": "Turpināt",
|
||||
"read_aloud_stop": "Beigt nolasīšanu"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Ārējs",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Atgriež visus šīs lapas iestatījumus noklusējuma vērtībās vai tajās, ko iestatījusi jūsu organizācija. Koplietojami iestatījumi, piemēram, sarunu skats, paliek, kā bijuši.",
|
||||
"button": "Atiestatīt",
|
||||
"done": "Pieejamības iestatījumi atiestatīti."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Nolasīšanas balss",
|
||||
"description": "Balss, kas nolasa ziņas. Sarakstā ir balsis, kas pārlūkam ir saskarnes valodai.",
|
||||
"default": "Pārlūka noklusējums"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Форматтайгаар харуулах",
|
||||
"shown_as_plain_text": "Энэ захидлыг энгийн текстээр харуулж байна.",
|
||||
"play_animation": "Хөдөлгөөнт зургийг тоглуулах",
|
||||
"pause_animation": "Хөдөлгөөнт зургийг түр зогсоох"
|
||||
"pause_animation": "Хөдөлгөөнт зургийг түр зогсоох",
|
||||
"read_aloud": "Чангаар унших",
|
||||
"read_aloud_reading": "Чангаар уншиж байна",
|
||||
"read_aloud_paused": "Уншихыг түр зогсоосон",
|
||||
"read_aloud_pause": "Түр зогсоох",
|
||||
"read_aloud_resume": "Үргэлжлүүлэх",
|
||||
"read_aloud_stop": "Уншихыг зогсоох"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Гадаад",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Энэ хуудасны бүх тохиргоог өгөгдмөл утга эсвэл байгууллагын тогтоосон утга руу буцаана. Харилцан ярианы харагдац зэрэг нийтлэг тохиргоо хэвээр үлдэнэ.",
|
||||
"button": "Дахин тохируулах",
|
||||
"done": "Хүртээмжийн тохиргоог дахин тохирууллаа."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Чангаар унших хоолой",
|
||||
"description": "Мессежийг чангаар унших хоолой. Жагсаалтад таны хөтөчид интерфейсийн хэлэнд байгаа хоолойнууд харагдана.",
|
||||
"default": "Хөтчийн өгөгдмөл"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Vis formatert",
|
||||
"shown_as_plain_text": "Denne meldingen vises som ren tekst.",
|
||||
"play_animation": "Spill av animasjon",
|
||||
"pause_animation": "Sett animasjon på pause"
|
||||
"pause_animation": "Sett animasjon på pause",
|
||||
"read_aloud": "Les opp",
|
||||
"read_aloud_reading": "Leser opp",
|
||||
"read_aloud_paused": "Opplesning satt på pause",
|
||||
"read_aloud_pause": "Pause",
|
||||
"read_aloud_resume": "Fortsett",
|
||||
"read_aloud_stop": "Stopp opplesning"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Ekstern",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Setter alle innstillinger på denne siden tilbake til standard, eller til det organisasjonen din har satt. Delte innstillinger, som samtalevisning, blir som de er.",
|
||||
"button": "Tilbakestill",
|
||||
"done": "Tilgjengelighetsinnstillingene er tilbakestilt."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Opplesningsstemme",
|
||||
"description": "Stemmen som leser opp meldinger. Listen viser stemmene nettleseren har for språket i grensesnittet.",
|
||||
"default": "Nettleserens standard"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Opgemaakt weergeven",
|
||||
"shown_as_plain_text": "Dit bericht wordt als platte tekst weergegeven.",
|
||||
"play_animation": "Animatie afspelen",
|
||||
"pause_animation": "Animatie pauzeren"
|
||||
"pause_animation": "Animatie pauzeren",
|
||||
"read_aloud": "Voorlezen",
|
||||
"read_aloud_reading": "Wordt voorgelezen",
|
||||
"read_aloud_paused": "Voorlezen gepauzeerd",
|
||||
"read_aloud_pause": "Pauzeren",
|
||||
"read_aloud_resume": "Hervatten",
|
||||
"read_aloud_stop": "Voorlezen stoppen"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Extern",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Zet elke instelling op deze pagina terug naar de standaardwaarde, of naar de waarde die je organisatie heeft ingesteld. Gedeelde instellingen zoals de gespreksweergave blijven zoals ze zijn.",
|
||||
"button": "Herstellen",
|
||||
"done": "Toegankelijkheidsinstellingen hersteld."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Voorleesstem",
|
||||
"description": "De stem die berichten voorleest. De lijst toont de stemmen die je browser heeft voor de taal van de interface.",
|
||||
"default": "Standaard van de browser"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Pokaż z formatowaniem",
|
||||
"shown_as_plain_text": "Ta wiadomość jest wyświetlana jako zwykły tekst.",
|
||||
"play_animation": "Odtwórz animację",
|
||||
"pause_animation": "Wstrzymaj animację"
|
||||
"pause_animation": "Wstrzymaj animację",
|
||||
"read_aloud": "Czytaj na głos",
|
||||
"read_aloud_reading": "Czytanie na głos",
|
||||
"read_aloud_paused": "Czytanie wstrzymane",
|
||||
"read_aloud_pause": "Wstrzymaj",
|
||||
"read_aloud_resume": "Wznów",
|
||||
"read_aloud_stop": "Zakończ czytanie"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Zewnętrzny",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Przywraca wszystkie ustawienia na tej stronie do domyślnych lub do wartości ustawionych przez Twoją organizację. Ustawienia wspólne, takie jak widok konwersacji, pozostają bez zmian.",
|
||||
"button": "Zresetuj",
|
||||
"done": "Ustawienia dostępności zresetowano."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Głos czytania na głos",
|
||||
"description": "Głos, który czyta wiadomości. Lista pokazuje głosy, które przeglądarka ma dla języka interfejsu.",
|
||||
"default": "Domyślny przeglądarki"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Mostrar formatado",
|
||||
"shown_as_plain_text": "Esta mensagem é mostrada como texto simples.",
|
||||
"play_animation": "Reproduzir animação",
|
||||
"pause_animation": "Pausar animação"
|
||||
"pause_animation": "Pausar animação",
|
||||
"read_aloud": "Ler em voz alta",
|
||||
"read_aloud_reading": "A ler em voz alta",
|
||||
"read_aloud_paused": "Leitura em pausa",
|
||||
"read_aloud_pause": "Pausar",
|
||||
"read_aloud_resume": "Retomar",
|
||||
"read_aloud_stop": "Parar a leitura"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Externo",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Volta todas as configurações desta página ao padrão ou ao valor definido pela sua organização. Configurações compartilhadas, como a visualização por conversa, não mudam.",
|
||||
"button": "Redefinir",
|
||||
"done": "Configurações de acessibilidade redefinidas."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Voz da leitura em voz alta",
|
||||
"description": "A voz que lê as mensagens em voz alta. A lista mostra as vozes que o navegador tem para o idioma da interface.",
|
||||
"default": "Padrão do navegador"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Afișați formatat",
|
||||
"shown_as_plain_text": "Acest mesaj este afișat ca text simplu.",
|
||||
"play_animation": "Redați animația",
|
||||
"pause_animation": "Întrerupeți animația"
|
||||
"pause_animation": "Întrerupeți animația",
|
||||
"read_aloud": "Citire cu voce tare",
|
||||
"read_aloud_reading": "Se citește cu voce tare",
|
||||
"read_aloud_paused": "Citire întreruptă",
|
||||
"read_aloud_pause": "Pauză",
|
||||
"read_aloud_resume": "Reluați",
|
||||
"read_aloud_stop": "Opriți citirea"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Extern",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Readuce toate setările de pe această pagină la valorile implicite sau la cele stabilite de organizația dvs. Setările comune, precum vizualizarea pe conversații, rămân neschimbate.",
|
||||
"button": "Resetează",
|
||||
"done": "Setările de accesibilitate au fost resetate."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Vocea pentru citire cu voce tare",
|
||||
"description": "Vocea care citește mesajele. Lista arată vocile pe care browserul le are pentru limba interfeței.",
|
||||
"default": "Implicită a browserului"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Показать с форматированием",
|
||||
"shown_as_plain_text": "Это письмо показано как обычный текст.",
|
||||
"play_animation": "Воспроизвести анимацию",
|
||||
"pause_animation": "Приостановить анимацию"
|
||||
"pause_animation": "Приостановить анимацию",
|
||||
"read_aloud": "Прочитать вслух",
|
||||
"read_aloud_reading": "Идёт чтение вслух",
|
||||
"read_aloud_paused": "Чтение приостановлено",
|
||||
"read_aloud_pause": "Пауза",
|
||||
"read_aloud_resume": "Продолжить",
|
||||
"read_aloud_stop": "Остановить чтение"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Внешнее",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Возвращает все настройки на этой странице к значениям по умолчанию или к тем, что задала ваша организация. Общие настройки, например режим разговоров, не меняются.",
|
||||
"button": "Сбросить",
|
||||
"done": "Настройки специальных возможностей сброшены."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Голос для чтения вслух",
|
||||
"description": "Голос, которым читаются письма. В списке голоса вашего браузера для языка интерфейса.",
|
||||
"default": "По умолчанию в браузере"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Zobraziť formátovane",
|
||||
"shown_as_plain_text": "Táto správa sa zobrazuje ako obyčajný text.",
|
||||
"play_animation": "Prehrať animáciu",
|
||||
"pause_animation": "Pozastaviť animáciu"
|
||||
"pause_animation": "Pozastaviť animáciu",
|
||||
"read_aloud": "Čítať nahlas",
|
||||
"read_aloud_reading": "Číta sa nahlas",
|
||||
"read_aloud_paused": "Čítanie pozastavené",
|
||||
"read_aloud_pause": "Pozastaviť",
|
||||
"read_aloud_resume": "Pokračovať",
|
||||
"read_aloud_stop": "Zastaviť čítanie"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Externé",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Vráti všetky nastavenia na tejto stránke na predvolené hodnoty alebo na hodnoty, ktoré určila vaša organizácia. Spoločné nastavenia, napríklad zobrazenie konverzácií, zostanú nezmenené.",
|
||||
"button": "Obnoviť",
|
||||
"done": "Nastavenia prístupnosti boli obnovené."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Hlas čítania nahlas",
|
||||
"description": "Hlas, ktorý číta správy nahlas. Zoznam ukazuje hlasy, ktoré má prehliadač pre jazyk rozhrania.",
|
||||
"default": "Predvolený v prehliadači"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -1706,7 +1706,13 @@
|
||||
"show_formatted": "แสดงแบบมีรูปแบบ",
|
||||
"shown_as_plain_text": "ข้อความนี้แสดงเป็นข้อความธรรมดา",
|
||||
"play_animation": "เล่นภาพเคลื่อนไหว",
|
||||
"pause_animation": "หยุดภาพเคลื่อนไหวชั่วคราว"
|
||||
"pause_animation": "หยุดภาพเคลื่อนไหวชั่วคราว",
|
||||
"read_aloud": "อ่านออกเสียง",
|
||||
"read_aloud_reading": "กำลังอ่านออกเสียง",
|
||||
"read_aloud_paused": "หยุดการอ่านชั่วคราว",
|
||||
"read_aloud_pause": "หยุดชั่วคราว",
|
||||
"read_aloud_resume": "อ่านต่อ",
|
||||
"read_aloud_stop": "หยุดอ่าน"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "ภายนอก",
|
||||
@@ -5567,6 +5573,11 @@
|
||||
"description": "คืนทุกการตั้งค่าในหน้านี้เป็นค่าเริ่มต้น หรือค่าที่องค์กรของคุณกำหนด การตั้งค่าที่ใช้ร่วมกัน เช่น มุมมองการสนทนา จะไม่เปลี่ยน",
|
||||
"button": "รีเซ็ต",
|
||||
"done": "รีเซ็ตการตั้งค่าการเข้าถึงแล้ว"
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "เสียงอ่านออกเสียง",
|
||||
"description": "เสียงที่ใช้อ่านข้อความ รายการแสดงเสียงที่เบราว์เซอร์ของคุณมีสำหรับภาษาของหน้าจอ",
|
||||
"default": "ค่าเริ่มต้นของเบราว์เซอร์"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Biçimli göster",
|
||||
"shown_as_plain_text": "Bu ileti düz metin olarak gösteriliyor.",
|
||||
"play_animation": "Animasyonu oynat",
|
||||
"pause_animation": "Animasyonu duraklat"
|
||||
"pause_animation": "Animasyonu duraklat",
|
||||
"read_aloud": "Sesli oku",
|
||||
"read_aloud_reading": "Sesli okunuyor",
|
||||
"read_aloud_paused": "Okuma duraklatıldı",
|
||||
"read_aloud_pause": "Duraklat",
|
||||
"read_aloud_resume": "Devam et",
|
||||
"read_aloud_stop": "Okumayı durdur"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Harici",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Bu sayfadaki tüm ayarları varsayılana veya kuruluşunuzun belirlediği değere döndürür. Konuşma görünümü gibi ortak ayarlar olduğu gibi kalır.",
|
||||
"button": "Sıfırla",
|
||||
"done": "Erişilebilirlik ayarları sıfırlandı."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Sesli okuma sesi",
|
||||
"description": "İletileri sesli okuyan ses. Liste, tarayıcınızda arayüz dili için bulunan sesleri gösterir.",
|
||||
"default": "Tarayıcı varsayılanı"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "Показати з форматуванням",
|
||||
"shown_as_plain_text": "Цей лист показано як звичайний текст.",
|
||||
"play_animation": "Відтворити анімацію",
|
||||
"pause_animation": "Призупинити анімацію"
|
||||
"pause_animation": "Призупинити анімацію",
|
||||
"read_aloud": "Прочитати вголос",
|
||||
"read_aloud_reading": "Триває читання вголос",
|
||||
"read_aloud_paused": "Читання призупинено",
|
||||
"read_aloud_pause": "Пауза",
|
||||
"read_aloud_resume": "Продовжити",
|
||||
"read_aloud_stop": "Зупинити читання"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Зовнішнє",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "Повертає всі налаштування на цій сторінці до типових значень або до тих, що задала ваша організація. Спільні налаштування, як-от режим розмов, не змінюються.",
|
||||
"button": "Скинути",
|
||||
"done": "Налаштування доступності скинуто."
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "Голос для читання вголос",
|
||||
"description": "Голос, яким читаються листи. У списку голоси вашого браузера для мови інтерфейсу.",
|
||||
"default": "Типовий у браузері"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "以格式顯示",
|
||||
"shown_as_plain_text": "此郵件以純文字顯示。",
|
||||
"play_animation": "播放動畫",
|
||||
"pause_animation": "暫停動畫"
|
||||
"pause_animation": "暫停動畫",
|
||||
"read_aloud": "朗讀",
|
||||
"read_aloud_reading": "正在朗讀",
|
||||
"read_aloud_paused": "朗讀已暫停",
|
||||
"read_aloud_pause": "暫停",
|
||||
"read_aloud_resume": "繼續",
|
||||
"read_aloud_stop": "停止朗讀"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "外部",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "將此頁面的所有設定還原為預設值,或還原為您所屬組織設定的值。對話檢視等共用設定維持不變。",
|
||||
"button": "重設",
|
||||
"done": "無障礙設定已重設。"
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "朗讀語音",
|
||||
"description": "朗讀郵件所用的語音。清單顯示瀏覽器為介面語言提供的語音。",
|
||||
"default": "瀏覽器預設"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-1
@@ -776,7 +776,13 @@
|
||||
"show_formatted": "以格式显示",
|
||||
"shown_as_plain_text": "此邮件以纯文本显示。",
|
||||
"play_animation": "播放动画",
|
||||
"pause_animation": "暂停动画"
|
||||
"pause_animation": "暂停动画",
|
||||
"read_aloud": "朗读",
|
||||
"read_aloud_reading": "正在朗读",
|
||||
"read_aloud_paused": "朗读已暂停",
|
||||
"read_aloud_pause": "暂停",
|
||||
"read_aloud_resume": "继续",
|
||||
"read_aloud_stop": "停止朗读"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "外部",
|
||||
@@ -3154,6 +3160,11 @@
|
||||
"description": "把此页面的所有设置恢复为默认值,或恢复为您所在组织设定的值。会话视图等共享设置保持不变。",
|
||||
"button": "重置",
|
||||
"done": "无障碍设置已重置。"
|
||||
},
|
||||
"read_aloud_voice": {
|
||||
"label": "朗读语音",
|
||||
"description": "朗读邮件所用的语音。列表显示浏览器为界面语言提供的语音。",
|
||||
"default": "浏览器默认"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user