diff --git a/app/main/materials/materialsOverlayStore.ts b/app/main/materials/materialsOverlayStore.ts index 67d9089..8221b7d 100644 --- a/app/main/materials/materialsOverlayStore.ts +++ b/app/main/materials/materialsOverlayStore.ts @@ -18,6 +18,13 @@ function emptyState(): MaterialsOverlayState { }; } +function initialLayout(rotationDeg?: number): MaterialsOverlayLayout { + return clampMaterialsLayout({ + ...DEFAULT_MATERIALS_OVERLAY_LAYOUT, + rotationDeg: rotationDeg ?? 0, + }); +} + export class MaterialsOverlayStore { private state: MaterialsOverlayState = emptyState(); @@ -46,7 +53,7 @@ export class MaterialsOverlayStore { this.state = { revision: this.state.revision + 1, activeMaterialId: event.materialId, - layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }, + layout: initialLayout(event.rotationDeg), zoomTool: this.state.zoomTool, }; return this.state; @@ -57,7 +64,7 @@ export class MaterialsOverlayStore { this.state = { revision: this.state.revision + 1, activeMaterialId: event.materialId, - layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }, + layout: initialLayout(event.rotationDeg), zoomTool: this.state.zoomTool, }; return this.state; @@ -67,7 +74,10 @@ export class MaterialsOverlayStore { this.state = { ...this.state, revision: this.state.revision + 1, - layout: clampMaterialsLayout(event.layout), + layout: clampMaterialsLayout({ + ...this.state.layout, + ...event.layout, + }), }; return this.state; } diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index b1b489a..5e5e68e 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -1593,12 +1593,12 @@ export function ControlApp() { return ( { void materialsApi.dispatch({ kind: 'hide' }); }} diff --git a/app/renderer/editor/MaterialsModals.tsx b/app/renderer/editor/MaterialsModals.tsx index 871e258..d05add4 100644 --- a/app/renderer/editor/MaterialsModals.tsx +++ b/app/renderer/editor/MaterialsModals.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; +import { createPortal, flushSync } from 'react-dom'; import type { MaterialId, ProjectMaterial } from '../../shared/types'; import { Button, Input } from '../shared/ui/controls'; @@ -63,11 +63,11 @@ export function MaterialEditModal({ useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === 'Escape' && !saving) onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [onClose, open]); + }, [onClose, open, saving]); const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => { setFilePath(path); @@ -102,13 +102,28 @@ export function MaterialEditModal({ return createPortal( <> - @@ -151,6 +166,7 @@ export function MaterialEditModal({
{t('materials.imageEmpty')}
)} diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 0fe5618..13928a6 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -54,6 +54,7 @@ export const EDITOR_MESSAGES: Record> = { 'common.close': 'Закрыть', 'common.cancel': 'Отмена', 'common.save': 'Сохранить', + 'common.saving': 'Сохранение…', 'common.edit': 'Редактировать', 'common.understood': 'Понятно', 'common.message': 'Сообщение', @@ -376,6 +377,7 @@ export const EDITOR_MESSAGES: Record> = { 'materials.tileMenu': 'Меню материала', 'materials.windowEmpty': 'Добавьте материалы в редакторе.', 'materials.closeOverlay': 'Закрыть материал', + 'materials.rotateOverlay': 'Повернуть', 'materials.deleteTitle': 'Удаление материала', 'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?', 'materials.zoomIn': 'Увеличить', @@ -591,6 +593,7 @@ export const EDITOR_MESSAGES: Record> = { 'common.close': 'Close', 'common.cancel': 'Cancel', 'common.save': 'Save', + 'common.saving': 'Saving…', 'common.edit': 'Edit', 'common.understood': 'OK', 'common.message': 'Message', @@ -913,6 +916,7 @@ export const EDITOR_MESSAGES: Record> = { 'materials.tileMenu': 'Material menu', 'materials.windowEmpty': 'Add materials in the editor.', 'materials.closeOverlay': 'Close material', + 'materials.rotateOverlay': 'Rotate', 'materials.deleteTitle': 'Delete material', 'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?', 'materials.zoomIn': 'Zoom in', diff --git a/app/renderer/materials/MaterialsApp.tsx b/app/renderer/materials/MaterialsApp.tsx index 1d91f6a..ea08496 100644 --- a/app/renderer/materials/MaterialsApp.tsx +++ b/app/renderer/materials/MaterialsApp.tsx @@ -90,7 +90,12 @@ export function MaterialsApp() { onSelect={onSelect} activeMaterialId={activeId} onTileActivate={(id) => { - void overlayApi.dispatch({ kind: 'toggle', materialId: id }); + const mat = materials.find((m) => m.id === id); + void overlayApi.dispatch({ + kind: 'toggle', + materialId: id, + rotationDeg: mat?.rotationDeg ?? 0, + }); }} toolbar={ <> diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index 355324b..2a0e7ba 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -167,7 +167,6 @@ export function PresentationView({ {activeMaterial ? ( ) : null} diff --git a/app/renderer/shared/materials/MaterialOverlay.tsx b/app/renderer/shared/materials/MaterialOverlay.tsx index b61162e..44f49ef 100644 --- a/app/renderer/shared/materials/MaterialOverlay.tsx +++ b/app/renderer/shared/materials/MaterialOverlay.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types'; +import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types'; import { useAssetUrl } from '../useAssetImageUrl'; import styles from './MaterialOverlay.module.css'; @@ -9,43 +10,89 @@ type Corner = 'nw' | 'ne' | 'sw' | 'se'; type MaterialOverlayProps = { assetId: AssetId | null; - rotationDeg?: 0 | 90 | 180 | 270; layout: MaterialsOverlayLayout; editable?: boolean; zoomTool?: MaterialsZoomTool | NpcsZoomTool; showClose?: boolean; onClose?: () => void; closeLabel?: string; + rotateLabel?: string; onLayoutChange?: (layout: MaterialsOverlayLayout) => void; onZoomAt?: (nx: number, ny: number) => void; }; +function RotateIcon() { + return ( + + + + + ); +} + function nextBaseSize( viewW: number, viewH: number, naturalW: number, naturalH: number, - rotationDeg: number, ): { w: number; h: number } { - const swapped = rotationDeg === 90 || rotationDeg === 270; - const iw = swapped ? naturalH : naturalW; - const ih = swapped ? naturalW : naturalH; - if (iw <= 0 || ih <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 }; + if (naturalW <= 0 || naturalH <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 }; const maxW = viewW * 0.92; const maxH = viewH * 0.88; - const fit = Math.min(maxW / iw, maxH / ih); - return { w: iw * fit, h: ih * fit }; + const fit = Math.min(maxW / naturalW, maxH / naturalH); + return { w: naturalW * fit, h: naturalH * fit }; +} + +function pointerAngleDeg(centerX: number, centerY: number, clientX: number, clientY: number): number { + return (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI; +} + +function shortestAngleDelta(fromDeg: number, toDeg: number): number { + let d = toDeg - fromDeg; + while (d > 180) d -= 360; + while (d < -180) d += 360; + return d; +} + +function screenToLocal( + clientX: number, + clientY: number, + centerX: number, + centerY: number, + rotationDeg: number, +): { x: number; y: number } { + const rad = (-rotationDeg * Math.PI) / 180; + const dx = clientX - centerX; + const dy = clientY - centerY; + return { + x: dx * Math.cos(rad) - dy * Math.sin(rad), + y: dx * Math.sin(rad) + dy * Math.cos(rad), + }; +} + +function localToScreenOffset(localX: number, localY: number, rotationDeg: number): { x: number; y: number } { + const rad = (rotationDeg * Math.PI) / 180; + return { + x: localX * Math.cos(rad) - localY * Math.sin(rad), + y: localX * Math.sin(rad) + localY * Math.cos(rad), + }; } export function MaterialOverlay({ assetId, - rotationDeg = 0, layout, editable = false, zoomTool = null, showClose = false, onClose, closeLabel = 'Close', + rotateLabel = 'Rotate', onLayoutChange, onZoomAt, }: MaterialOverlayProps) { @@ -58,13 +105,20 @@ export function MaterialOverlay({ | { mode: 'resize'; corner: Corner; - startX: number; - startY: number; origin: MaterialsOverlayLayout; baseW: number; baseH: number; viewW: number; viewH: number; + centerClientX: number; + centerClientY: number; + } + | { + mode: 'rotate'; + origin: MaterialsOverlayLayout; + startPointerAngle: number; + centerX: number; + centerY: number; } | null >(null); @@ -85,15 +139,13 @@ export function MaterialOverlay({ const zoomCursor = zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : ''; - const base = nextBaseSize(view.w, view.h, natural.w, natural.h, rotationDeg); - const w = base.w * layout.scale; - const h = base.h * layout.scale; - const left = layout.cx * view.w - w / 2; - const top = layout.cy * view.h - h / 2; - // Рамка — AABB; до CSS-rotate картинка имеет «натуральную» ориентацию. - const swapped = rotationDeg === 90 || rotationDeg === 270; - const contentW = swapped ? h : w; - const contentH = swapped ? w : h; + const effectiveLayout = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT; + const rotationDeg = effectiveLayout.rotationDeg ?? 0; + const base = nextBaseSize(view.w, view.h, natural.w, natural.h); + const w = base.w * effectiveLayout.scale; + const h = base.h * effectiveLayout.scale; + const left = effectiveLayout.cx * view.w - w / 2; + const top = effectiveLayout.cy * view.h - h / 2; const toNorm = (clientX: number, clientY: number) => { const root = rootRef.current; @@ -105,16 +157,37 @@ export function MaterialOverlay({ }; }; + const layoutCenterClient = () => { + const root = rootRef.current; + if (!root) return { x: 0, y: 0 }; + const r = root.getBoundingClientRect(); + return { + x: r.left + effectiveLayout.cx * r.width, + y: r.top + effectiveLayout.cy * r.height, + }; + }; + const onPointerMove = (e: PointerEvent) => { const drag = dragRef.current; if (!drag || !onLayoutChange) return; + + if (drag.mode === 'rotate') { + const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY); + const delta = shortestAngleDelta(drag.startPointerAngle, angle); + onLayoutChange({ + ...drag.origin, + rotationDeg: drag.origin.rotationDeg + delta, + }); + return; + } + const root = rootRef.current; if (!root) return; const r = root.getBoundingClientRect(); - const dx = (e.clientX - drag.startX) / Math.max(1, r.width); - const dy = (e.clientY - drag.startY) / Math.max(1, r.height); if (drag.mode === 'move') { + const dx = (e.clientX - drag.startX) / Math.max(1, r.width); + const dy = (e.clientY - drag.startY) / Math.max(1, r.height); onLayoutChange({ ...drag.origin, cx: drag.origin.cx + dx, @@ -123,13 +196,13 @@ export function MaterialOverlay({ return; } - const { baseW, baseH, viewW, viewH, origin, corner } = drag; + const { baseW, baseH, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag; const originW = baseW * origin.scale; const originH = baseH * origin.scale; - const originLeft = origin.cx * viewW - originW / 2; - const originTop = origin.cy * viewH - originH / 2; - const originRight = originLeft + originW; - const originBottom = originTop + originH; + const originLeft = -originW / 2; + const originTop = -originH / 2; + const originRight = originW / 2; + const originBottom = originH / 2; let anchorX = originLeft; let anchorY = originTop; @@ -144,10 +217,15 @@ export function MaterialOverlay({ anchorY = originTop; } - const pointerX = e.clientX - r.left; - const pointerY = e.clientY - r.top; - const newW = Math.max(8, Math.abs(pointerX - anchorX)); - const newH = Math.max(8, Math.abs(pointerY - anchorY)); + const local = screenToLocal( + e.clientX, + e.clientY, + centerClientX, + centerClientY, + origin.rotationDeg ?? 0, + ); + const newW = Math.max(8, Math.abs(local.x - anchorX)); + const newH = Math.max(8, Math.abs(local.y - anchorY)); const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH)); const ww = baseW * nextScale; const hh = baseH * nextScale; @@ -165,9 +243,14 @@ export function MaterialOverlay({ nextTop = anchorY; } + const localCenterX = nextLeft + ww / 2; + const localCenterY = nextTop + hh / 2; + const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0); + onLayoutChange({ - cx: (nextLeft + ww / 2) / Math.max(1, viewW), - cy: (nextTop + hh / 2) / Math.max(1, viewH), + ...origin, + cx: origin.cx + screenOffset.x / Math.max(1, viewW), + cy: origin.cy + screenOffset.y / Math.max(1, viewH), scale: nextScale, }); }; @@ -182,20 +265,43 @@ export function MaterialOverlay({ if (!editable || !onLayoutChange || zoomTool) return; e.preventDefault(); e.stopPropagation(); - dragRef.current = - mode === 'move' - ? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } } - : { - mode: 'resize', - corner: corner ?? 'se', - startX: e.clientX, - startY: e.clientY, - origin: { ...layout }, - baseW: base.w, - baseH: base.h, - viewW: view.w, - viewH: view.h, - }; + if (mode === 'move') { + dragRef.current = { + mode: 'move', + startX: e.clientX, + startY: e.clientY, + origin: { ...effectiveLayout }, + }; + } else { + const center = layoutCenterClient(); + dragRef.current = { + mode: 'resize', + corner: corner ?? 'se', + origin: { ...effectiveLayout }, + baseW: base.w, + baseH: base.h, + viewW: view.w, + viewH: view.h, + centerClientX: center.x, + centerClientY: center.y, + }; + } + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', endDrag); + }; + + const startRotate = (e: React.PointerEvent) => { + if (!editable || !onLayoutChange || zoomTool) return; + e.preventDefault(); + e.stopPropagation(); + const center = layoutCenterClient(); + dragRef.current = { + mode: 'rotate', + origin: { ...effectiveLayout }, + startPointerAngle: pointerAngleDeg(center.x, center.y, e.clientX, e.clientY), + centerX: center.x, + centerY: center.y, + }; window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', endDrag); }; @@ -220,7 +326,14 @@ export function MaterialOverlay({ className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''] .filter(Boolean) .join(' ')} - style={{ left, top, width: w, height: h }} + style={{ + left, + top, + width: w, + height: h, + transform: `rotate(${String(rotationDeg)}deg)`, + transformOrigin: 'center center', + }} onPointerDown={(e) => { if (zoomTool) return; startDrag(e, 'move'); @@ -232,9 +345,9 @@ export function MaterialOverlay({ alt="" draggable={false} style={{ - width: contentW, - height: contentH, - transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`, + width: w, + height: h, + transform: 'translate(-50%, -50%)', }} onLoad={(e) => { const img = e.currentTarget; @@ -252,6 +365,17 @@ export function MaterialOverlay({ /> )) : null} + {editable && !zoomTool && onLayoutChange ? ( + + ) : null} {showClose ? (