Редактор: превью с поворотом, проекты, безопасное сохранение 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:
@@ -65,6 +65,7 @@ export function EditorApp() {
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [previewBusy, setPreviewBusy] = useState(false);
|
||||
const [presentationOpen, setPresentationOpen] = useState(false);
|
||||
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
|
||||
const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
|
||||
const [eulaModalOpen, setEulaModalOpen] = useState(false);
|
||||
@@ -216,6 +217,24 @@ export function EditorApp() {
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [settingsMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let off: (() => void) | null = null;
|
||||
void (async () => {
|
||||
try {
|
||||
const snap = await getDndApi().invoke(ipcChannels.windows.getMultiWindowState, {});
|
||||
setPresentationOpen(snap.open);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
||||
setPresentationOpen(open);
|
||||
});
|
||||
})();
|
||||
return () => {
|
||||
off?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const reloadLicense = useCallback(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -266,6 +285,19 @@ export function EditorApp() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{presentationOpen
|
||||
? createPortal(
|
||||
<div className={styles.editorLockOverlay} role="dialog" aria-label="Презентация запущена">
|
||||
<div className={styles.editorLockModal}>
|
||||
<div className={styles.editorLockTitle}>Презентация запущена</div>
|
||||
<div className={styles.editorLockText}>
|
||||
Редактор заблокирован. Закройте окна «Презентация» и «Панель управления», чтобы продолжить.
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{state.zipProgress
|
||||
? createPortal(
|
||||
<div className={styles.progressOverlay} role="dialog" aria-label="Прогресс операции">
|
||||
@@ -413,6 +445,7 @@ export function EditorApp() {
|
||||
) : (
|
||||
<ProjectPicker
|
||||
projects={state.projects}
|
||||
licenseActive={licenseActive}
|
||||
onCreate={actions.createProject}
|
||||
onOpen={actions.openProject}
|
||||
onDelete={actions.deleteProject}
|
||||
@@ -928,12 +961,13 @@ function RenameProjectModal({
|
||||
|
||||
type ProjectPickerProps = {
|
||||
projects: { id: ProjectId; name: string; updatedAt: string }[];
|
||||
licenseActive: boolean;
|
||||
onCreate: (name: string) => Promise<void>;
|
||||
onOpen: (id: ProjectId) => Promise<void>;
|
||||
onDelete: (id: ProjectId) => Promise<void>;
|
||||
};
|
||||
|
||||
function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerProps) {
|
||||
function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: ProjectPickerProps) {
|
||||
const [name, setName] = useState('Моя кампания');
|
||||
const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null);
|
||||
const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
@@ -956,23 +990,46 @@ function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerPr
|
||||
<div className={styles.projectPickerTitle}>Проекты</div>
|
||||
<div className={styles.projectPickerForm}>
|
||||
<Input value={name} onChange={setName} placeholder="Название нового проекта…" />
|
||||
<Button variant="primary" onClick={() => void onCreate(name)}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!licenseActive}
|
||||
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
|
||||
onClick={() => {
|
||||
if (!licenseActive) return;
|
||||
void onCreate(name);
|
||||
}}
|
||||
>
|
||||
Создать проект
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.spacer6} />
|
||||
<div className={styles.sectionLabel}>СУЩЕСТВУЮЩИЕ</div>
|
||||
{!licenseActive && projects.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.muted}>
|
||||
Открытие и создание — после активации лицензии. Список показывает файлы в папке приложения.
|
||||
</div>
|
||||
<div className={styles.spacer6} />
|
||||
</>
|
||||
) : null}
|
||||
<div className={styles.projectListScroll}>
|
||||
<div className={styles.projectList}>
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className={styles.projectCard}>
|
||||
<div
|
||||
className={styles.projectCardBody}
|
||||
onClick={() => void onOpen(p.id)}
|
||||
onClick={() => {
|
||||
if (!licenseActive) return;
|
||||
void onOpen(p.id);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={!licenseActive ? 'Открытие проекта — после активации лицензии' : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') void onOpen(p.id);
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (!licenseActive) return;
|
||||
void onOpen(p.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.projectCardName}>{p.name}</div>
|
||||
@@ -985,8 +1042,11 @@ function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerPr
|
||||
aria-label="Меню проекта"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={rowMenuFor === p.id}
|
||||
disabled={!licenseActive}
|
||||
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!licenseActive) return;
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const menuW = 220;
|
||||
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
|
||||
@@ -1189,17 +1249,26 @@ function SceneInspector({
|
||||
<div className={styles.hint}>Файл изображения (PNG, JPG, WebP, GIF и т.д.).</div>
|
||||
<div className={styles.previewBox}>
|
||||
{previewUrl && previewAssetType === 'image' ? (
|
||||
<RotatedImage url={previewUrl} rotationDeg={previewRotationDeg} mode="cover" />
|
||||
<div className={styles.previewFill}>
|
||||
<RotatedImage
|
||||
url={previewUrl}
|
||||
rotationDeg={previewRotationDeg}
|
||||
mode="cover"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
) : previewUrl && previewAssetType === 'video' ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={previewVideoAutostart}
|
||||
loop
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
/>
|
||||
<div className={styles.previewFill}>
|
||||
<video
|
||||
src={previewUrl}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={previewVideoAutostart}
|
||||
loop
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.previewEmpty}>Превью не задано</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user