mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 11:20:15 +01:00
🔊 refactor: Optimize Aria-Live Announcements for macOS VoiceOver (#3851)
This commit is contained in:
parent
757b6d3275
commit
dc40e577af
5 changed files with 59 additions and 170 deletions
|
|
@ -3,17 +3,17 @@ import React from 'react';
|
|||
|
||||
interface AnnouncerProps {
|
||||
statusMessage: string;
|
||||
responseMessage: string;
|
||||
logMessage: string;
|
||||
}
|
||||
|
||||
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, responseMessage }) => {
|
||||
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, logMessage }) => {
|
||||
return (
|
||||
<div className="sr-only">
|
||||
<div aria-live="assertive" aria-atomic="true">
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
{statusMessage}
|
||||
</div>
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
{responseMessage}
|
||||
{logMessage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// client/src/a11y/LiveAnnouncer.tsx
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { findLastSeparatorIndex } from 'librechat-data-provider';
|
||||
import type { AnnounceOptions } from '~/Providers/AnnouncerContext';
|
||||
import AnnouncerContext from '~/Providers/AnnouncerContext';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
|
|
@ -10,161 +9,53 @@ interface LiveAnnouncerProps {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface AnnouncementItem {
|
||||
message: string;
|
||||
id: string;
|
||||
isAssertive: boolean;
|
||||
}
|
||||
|
||||
/** Chunk size for processing text */
|
||||
const CHUNK_SIZE = 200;
|
||||
/** Minimum delay between announcements */
|
||||
const MIN_ANNOUNCEMENT_DELAY = 1000;
|
||||
/** Delay before clearing the live region */
|
||||
const CLEAR_DELAY = 5000;
|
||||
/** Regex to remove *, `, and _ from message text */
|
||||
const replacementRegex = /[*`_]/g;
|
||||
|
||||
const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [responseMessage, setResponseMessage] = useState('');
|
||||
const [logMessage, setLogMessage] = useState('');
|
||||
|
||||
const counterRef = useRef(0);
|
||||
const isAnnouncingRef = useRef(false);
|
||||
const politeProcessedTextRef = useRef('');
|
||||
const queueRef = useRef<AnnouncementItem[]>([]);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastAnnouncementTimeRef = useRef(0);
|
||||
const statusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const localize = useLocalize();
|
||||
|
||||
/** Generates a unique ID for announcement messages */
|
||||
const generateUniqueId = (prefix: string) => {
|
||||
counterRef.current += 1;
|
||||
return `${prefix}-${counterRef.current}`;
|
||||
};
|
||||
|
||||
/** Processes the text in chunks and returns a chunk of text */
|
||||
const processChunks = (text: string, processedTextRef: React.MutableRefObject<string>) => {
|
||||
const remainingText = text.slice(processedTextRef.current.length);
|
||||
|
||||
if (remainingText.length < CHUNK_SIZE) {
|
||||
return ''; /* Not enough characters to process */
|
||||
}
|
||||
|
||||
let separatorIndex = -1;
|
||||
let startIndex = CHUNK_SIZE;
|
||||
|
||||
while (separatorIndex === -1 && startIndex <= remainingText.length) {
|
||||
separatorIndex = findLastSeparatorIndex(remainingText.slice(startIndex));
|
||||
if (separatorIndex !== -1) {
|
||||
separatorIndex += startIndex; /* Adjust the index to account for the starting position */
|
||||
} else {
|
||||
startIndex += CHUNK_SIZE; /* Move the starting position by another CHUNK_SIZE characters */
|
||||
}
|
||||
}
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
return ''; /* No separator found, wait for more text */
|
||||
}
|
||||
|
||||
const chunkText = remainingText.slice(0, separatorIndex + 1);
|
||||
processedTextRef.current += chunkText;
|
||||
return chunkText.trim();
|
||||
};
|
||||
|
||||
/** Localized event announcements, i.e., "the AI is replying, finished, etc." */
|
||||
const events: Record<string, string | undefined> = useMemo(
|
||||
() => ({ start: localize('com_a11y_start'), end: localize('com_a11y_end') }),
|
||||
() => ({
|
||||
start: localize('com_a11y_start'),
|
||||
end: localize('com_a11y_end'),
|
||||
composing: localize('com_a11y_ai_composing'),
|
||||
}),
|
||||
[localize],
|
||||
);
|
||||
|
||||
const announceMessage = useCallback(
|
||||
(message: string, isAssertive: boolean) => {
|
||||
const setMessage = isAssertive ? setStatusMessage : setResponseMessage;
|
||||
setMessage(message);
|
||||
const announceStatus = useCallback((message: string) => {
|
||||
if (statusTimeoutRef.current) {
|
||||
clearTimeout(statusTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setStatusMessage(message);
|
||||
|
||||
lastAnnouncementTimeRef.current = Date.now();
|
||||
isAnnouncingRef.current = true;
|
||||
statusTimeoutRef.current = setTimeout(() => {
|
||||
setStatusMessage('');
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
isAnnouncingRef.current = false;
|
||||
setMessage(''); // Clear the message after a delay
|
||||
if (queueRef.current.length > 0) {
|
||||
const nextAnnouncement = queueRef.current.shift();
|
||||
if (nextAnnouncement) {
|
||||
const { message: _msg, isAssertive } = nextAnnouncement;
|
||||
const nextMessage = (events[_msg] ?? _msg).replace(replacementRegex, '');
|
||||
announceMessage(nextMessage, isAssertive);
|
||||
}
|
||||
}
|
||||
},
|
||||
isAssertive ? MIN_ANNOUNCEMENT_DELAY : CLEAR_DELAY,
|
||||
);
|
||||
},
|
||||
[events],
|
||||
);
|
||||
const announceLog = useCallback((message: string) => {
|
||||
setLogMessage(message);
|
||||
}, []);
|
||||
|
||||
const addToQueue = useCallback(
|
||||
(item: AnnouncementItem) => {
|
||||
if (item.isAssertive) {
|
||||
/* For assertive messages, clear the queue and announce immediately */
|
||||
queueRef.current = [];
|
||||
const { message: _msg, isAssertive } = item;
|
||||
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
|
||||
announceMessage(message, isAssertive);
|
||||
} else {
|
||||
queueRef.current.push(item);
|
||||
if (!isAnnouncingRef.current) {
|
||||
const nextAnnouncement = queueRef.current.shift();
|
||||
if (nextAnnouncement) {
|
||||
const { message: _msg, isAssertive } = nextAnnouncement;
|
||||
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
|
||||
announceMessage(message, isAssertive);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[events, announceMessage],
|
||||
);
|
||||
|
||||
/** Announces a polite message */
|
||||
const announcePolite = useCallback(
|
||||
({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => {
|
||||
const announcementId = id ?? generateUniqueId('polite');
|
||||
if (isStream || isComplete) {
|
||||
const chunk = processChunks(message, politeProcessedTextRef);
|
||||
if (chunk) {
|
||||
addToQueue({ message: chunk, id: announcementId, isAssertive: false });
|
||||
}
|
||||
if (isComplete) {
|
||||
const remainingText = message.slice(politeProcessedTextRef.current.length);
|
||||
if (remainingText.trim()) {
|
||||
addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false });
|
||||
}
|
||||
politeProcessedTextRef.current = '';
|
||||
}
|
||||
({ message, isStatus = false }: AnnounceOptions) => {
|
||||
const finalMessage = (events[message] ?? message).replace(/[*`_]/g, '');
|
||||
|
||||
if (isStatus) {
|
||||
announceStatus(finalMessage);
|
||||
} else {
|
||||
addToQueue({ message, id: announcementId, isAssertive: false });
|
||||
politeProcessedTextRef.current = '';
|
||||
announceLog(finalMessage);
|
||||
}
|
||||
},
|
||||
[addToQueue],
|
||||
[events, announceStatus, announceLog],
|
||||
);
|
||||
|
||||
/** Announces an assertive message */
|
||||
const announceAssertive = useCallback(
|
||||
({ message, id }: AnnounceOptions) => {
|
||||
const announcementId = id ?? generateUniqueId('assertive');
|
||||
addToQueue({ message, id: announcementId, isAssertive: true });
|
||||
},
|
||||
[addToQueue],
|
||||
);
|
||||
const announceAssertive = announcePolite;
|
||||
|
||||
const contextValue = {
|
||||
announcePolite,
|
||||
|
|
@ -173,10 +64,8 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
|
|||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
queueRef.current = [];
|
||||
isAnnouncingRef.current = false;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
if (statusTimeoutRef.current) {
|
||||
clearTimeout(statusTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
|
@ -184,7 +73,7 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
|
|||
return (
|
||||
<AnnouncerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<Announcer statusMessage={statusMessage} responseMessage={responseMessage} />
|
||||
<Announcer statusMessage={statusMessage} logMessage={logMessage} />
|
||||
</AnnouncerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue