feat(channels): фон комнаты и статичные превью при reduced-motion
- миграция 00014: background_file_id у комнаты (AGENT.md 7.5, 7.7)
- API: POST/DELETE /channels/{id}/background под MANAGE_CHANNEL_BACKGROUND,
фон отдаётся в списке комнат и рассылается событием CHANNEL_UPDATE
- клиент: фон рисуется за лентой с затемнением, блок «Фон комнаты» в настройках
комнаты (загрузка, замена, удаление)
- AnimatedImage: при «уменьшить движение» показывает статичный первый кадр,
нарисованный на canvas, вместо анимации
- тесты: Go (загрузка, права, снятие) и Vitest (лента, настройки, reduced-motion)
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Анимированные картинки и настройка «уменьшить движение» (AGENT.md 7.2, 7.7):
|
||||
* если пользователь просил меньше анимации, показываем статичный первый кадр.
|
||||
* Кадр рисуется на canvas — серверную генерацию превью ждать не нужно.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Запрос «уменьшить движение» из системных настроек. */
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/** Подписка на изменение системной настройки движения. */
|
||||
export function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(prefersReducedMotion);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const listener = (event: MediaQueryListEvent): void => setReduced(event.matches);
|
||||
query.addEventListener('change', listener);
|
||||
return () => query.removeEventListener('change', listener);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
|
||||
/** Подпись canvas: первый кадр картинки в data URL. */
|
||||
function firstFrameUrl(image: HTMLImageElement): string | null {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
if (canvas.width === 0 || canvas.height === 0) {
|
||||
return null;
|
||||
}
|
||||
const context = canvas.getContext('2d');
|
||||
if (context === null) {
|
||||
return null;
|
||||
}
|
||||
context.drawImage(image, 0, 0);
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
// Чужая картинка без CORS: canvas «испорчен», статичный кадр не сделать.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useStaticFrame возвращает ссылку на первый кадр картинки, когда анимация
|
||||
* нежелательна; иначе — исходную ссылку. Кадр привязан к источнику, поэтому
|
||||
* смена картинки не показывает чужой кадр.
|
||||
*/
|
||||
export function useStaticFrame(src: string | undefined, reduced: boolean): string | undefined {
|
||||
const [frame, setFrame] = useState<{ src: string; url: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (src === undefined || src === '' || !reduced) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => {
|
||||
const url = firstFrameUrl(image);
|
||||
if (!cancelled && url !== null) {
|
||||
setFrame({ src, url });
|
||||
}
|
||||
};
|
||||
image.src = src;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src, reduced]);
|
||||
|
||||
if (!reduced || src === undefined || frame === null || frame.src !== src) {
|
||||
return src;
|
||||
}
|
||||
return frame.url;
|
||||
}
|
||||
Reference in New Issue
Block a user