Редактор: превью с поворотом, проекты, безопасное сохранение zip, dev-меню
RotatedImage: размер контейнера через clientWidth/Height (не getBoundingClientRect), чтобы cover при 90°/270° работал под zoom React Flow; убраны отладочные логи. Главное меню в dev: пункт «Вид» с DevTools (Ctrl+Shift+I без пустого application menu). Список проектов: project.list без лицензии; список подгружается при неактивной лицензии; ProjectPicker с подсказками; listProjects пропускает битые zip. Сохранение проектов: atomicReplace — замена zip без rm до commit; восстановление *.dnd.zip.tmp при старте; тесты. EditorApp: блокировка UI при открытых окнах презентации и пульта; стили оверлея. Made-with: Cursor
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import styles from './RotatedImage.module.css';
|
||||
|
||||
@@ -24,13 +24,15 @@ function useElementSize<T extends HTMLElement>() {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// clientWidth/Height — локальная вёрстка; getBoundingClientRect учитывает transform предков (React Flow zoom).
|
||||
const readLayoutSize = () => {
|
||||
setSize({ w: el.clientWidth, h: el.clientHeight });
|
||||
};
|
||||
const ro = new ResizeObserver(() => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setSize({ w: r.width, h: r.height });
|
||||
readLayoutSize();
|
||||
});
|
||||
ro.observe(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
setSize({ w: r.width, h: r.height });
|
||||
readLayoutSize();
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
@@ -49,18 +51,19 @@ export function RotatedImage({
|
||||
}: RotatedImageProps) {
|
||||
const [ref, size] = useElementSize<HTMLDivElement>();
|
||||
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
setImgSize({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
||||
};
|
||||
img.src = url;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
useLayoutEffect(() => {
|
||||
// If the image is served from cache, onLoad may fire before listeners attach.
|
||||
// Reading from the <img> element itself is the most reliable source.
|
||||
const el = imgRef.current;
|
||||
if (!el) return;
|
||||
if (!el.complete) return;
|
||||
const w0 = el.naturalWidth || 0;
|
||||
const h0 = el.naturalHeight || 0;
|
||||
if (w0 <= 0 || h0 <= 0) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect, @typescript-eslint/prefer-optional-chain -- read cached <img> dimensions when onLoad may not fire
|
||||
setImgSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
|
||||
}, [url]);
|
||||
|
||||
const scale = useMemo(() => {
|
||||
@@ -93,11 +96,23 @@ export function RotatedImage({
|
||||
return (
|
||||
<div ref={ref} className={styles.root} style={style}>
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt={alt}
|
||||
src={url}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
className={styles.img}
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget;
|
||||
const w0 = el.naturalWidth || 0;
|
||||
const h0 = el.naturalHeight || 0;
|
||||
if (w0 <= 0 || h0 <= 0) return;
|
||||
setImgSize((prev) => {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- rule can misfire on React state unions
|
||||
if (prev && prev.w === w0 && prev.h === h0) return prev;
|
||||
return { w: w0, h: h0 };
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
width: w ?? '100%',
|
||||
height: h ?? '100%',
|
||||
|
||||
Reference in New Issue
Block a user