feat(materials): free rotate session overlays and show save progress
Match NPC drag-to-rotate on the material frame during playback, and change the add/edit Save button to Saving… while import runs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,13 @@ function emptyState(): MaterialsOverlayState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initialLayout(rotationDeg?: number): MaterialsOverlayLayout {
|
||||||
|
return clampMaterialsLayout({
|
||||||
|
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||||
|
rotationDeg: rotationDeg ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export class MaterialsOverlayStore {
|
export class MaterialsOverlayStore {
|
||||||
private state: MaterialsOverlayState = emptyState();
|
private state: MaterialsOverlayState = emptyState();
|
||||||
|
|
||||||
@@ -46,7 +53,7 @@ export class MaterialsOverlayStore {
|
|||||||
this.state = {
|
this.state = {
|
||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
activeMaterialId: event.materialId,
|
activeMaterialId: event.materialId,
|
||||||
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
|
layout: initialLayout(event.rotationDeg),
|
||||||
zoomTool: this.state.zoomTool,
|
zoomTool: this.state.zoomTool,
|
||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
@@ -57,7 +64,7 @@ export class MaterialsOverlayStore {
|
|||||||
this.state = {
|
this.state = {
|
||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
activeMaterialId: event.materialId,
|
activeMaterialId: event.materialId,
|
||||||
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
|
layout: initialLayout(event.rotationDeg),
|
||||||
zoomTool: this.state.zoomTool,
|
zoomTool: this.state.zoomTool,
|
||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
@@ -67,7 +74,10 @@ export class MaterialsOverlayStore {
|
|||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
layout: clampMaterialsLayout(event.layout),
|
layout: clampMaterialsLayout({
|
||||||
|
...this.state.layout,
|
||||||
|
...event.layout,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1593,12 +1593,12 @@ export function ControlApp() {
|
|||||||
return (
|
return (
|
||||||
<MaterialOverlay
|
<MaterialOverlay
|
||||||
assetId={activeMaterial.assetId}
|
assetId={activeMaterial.assetId}
|
||||||
rotationDeg={activeMaterial.rotationDeg ?? 0}
|
|
||||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||||
editable
|
editable
|
||||||
zoomTool={materialsOverlay?.zoomTool ?? null}
|
zoomTool={materialsOverlay?.zoomTool ?? null}
|
||||||
showClose
|
showClose
|
||||||
closeLabel={t('materials.closeOverlay')}
|
closeLabel={t('materials.closeOverlay')}
|
||||||
|
rotateLabel={t('materials.rotateOverlay')}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
void materialsApi.dispatch({ kind: 'hide' });
|
void materialsApi.dispatch({ kind: 'hide' });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
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 type { MaterialId, ProjectMaterial } from '../../shared/types';
|
||||||
import { Button, Input } from '../shared/ui/controls';
|
import { Button, Input } from '../shared/ui/controls';
|
||||||
@@ -63,11 +63,11 @@ export function MaterialEditModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape' && !saving) onClose();
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [onClose, open]);
|
}, [onClose, open, saving]);
|
||||||
|
|
||||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||||
setFilePath(path);
|
setFilePath(path);
|
||||||
@@ -102,13 +102,28 @@ export function MaterialEditModal({
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
onClick={() => {
|
||||||
|
if (!saving) onClose();
|
||||||
|
}}
|
||||||
|
className={styles.modalBackdrop}
|
||||||
|
/>
|
||||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<div className={styles.modalTitle}>
|
<div className={styles.modalTitle}>
|
||||||
{initial ? t('materials.editTitle') : t('materials.addTitle')}
|
{initial ? t('materials.editTitle') : t('materials.addTitle')}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
onClick={() => {
|
||||||
|
if (!saving) onClose();
|
||||||
|
}}
|
||||||
|
className={styles.modalClose}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,6 +166,7 @@ export function MaterialEditModal({
|
|||||||
<div className={styles.muted}>{t('materials.imageEmpty')}</div>
|
<div className={styles.muted}>{t('materials.imageEmpty')}</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
disabled={saving}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const picked = await onPickImage();
|
const picked = await onPickImage();
|
||||||
@@ -177,8 +193,10 @@ export function MaterialEditModal({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
flushSync(() => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
|
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
|
||||||
onClose();
|
onClose();
|
||||||
@@ -190,7 +208,7 @@ export function MaterialEditModal({
|
|||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('common.save')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'common.close': 'Закрыть',
|
'common.close': 'Закрыть',
|
||||||
'common.cancel': 'Отмена',
|
'common.cancel': 'Отмена',
|
||||||
'common.save': 'Сохранить',
|
'common.save': 'Сохранить',
|
||||||
|
'common.saving': 'Сохранение…',
|
||||||
'common.edit': 'Редактировать',
|
'common.edit': 'Редактировать',
|
||||||
'common.understood': 'Понятно',
|
'common.understood': 'Понятно',
|
||||||
'common.message': 'Сообщение',
|
'common.message': 'Сообщение',
|
||||||
@@ -376,6 +377,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'materials.tileMenu': 'Меню материала',
|
'materials.tileMenu': 'Меню материала',
|
||||||
'materials.windowEmpty': 'Добавьте материалы в редакторе.',
|
'materials.windowEmpty': 'Добавьте материалы в редакторе.',
|
||||||
'materials.closeOverlay': 'Закрыть материал',
|
'materials.closeOverlay': 'Закрыть материал',
|
||||||
|
'materials.rotateOverlay': 'Повернуть',
|
||||||
'materials.deleteTitle': 'Удаление материала',
|
'materials.deleteTitle': 'Удаление материала',
|
||||||
'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?',
|
'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?',
|
||||||
'materials.zoomIn': 'Увеличить',
|
'materials.zoomIn': 'Увеличить',
|
||||||
@@ -591,6 +593,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'common.close': 'Close',
|
'common.close': 'Close',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
|
'common.saving': 'Saving…',
|
||||||
'common.edit': 'Edit',
|
'common.edit': 'Edit',
|
||||||
'common.understood': 'OK',
|
'common.understood': 'OK',
|
||||||
'common.message': 'Message',
|
'common.message': 'Message',
|
||||||
@@ -913,6 +916,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'materials.tileMenu': 'Material menu',
|
'materials.tileMenu': 'Material menu',
|
||||||
'materials.windowEmpty': 'Add materials in the editor.',
|
'materials.windowEmpty': 'Add materials in the editor.',
|
||||||
'materials.closeOverlay': 'Close material',
|
'materials.closeOverlay': 'Close material',
|
||||||
|
'materials.rotateOverlay': 'Rotate',
|
||||||
'materials.deleteTitle': 'Delete material',
|
'materials.deleteTitle': 'Delete material',
|
||||||
'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?',
|
'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?',
|
||||||
'materials.zoomIn': 'Zoom in',
|
'materials.zoomIn': 'Zoom in',
|
||||||
|
|||||||
@@ -90,7 +90,12 @@ export function MaterialsApp() {
|
|||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
activeMaterialId={activeId}
|
activeMaterialId={activeId}
|
||||||
onTileActivate={(id) => {
|
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={
|
toolbar={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ export function PresentationView({
|
|||||||
{activeMaterial ? (
|
{activeMaterial ? (
|
||||||
<MaterialOverlay
|
<MaterialOverlay
|
||||||
assetId={activeMaterial.assetId}
|
assetId={activeMaterial.assetId}
|
||||||
rotationDeg={activeMaterial.rotationDeg ?? 0}
|
|
||||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||||||
|
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||||
import { useAssetUrl } from '../useAssetImageUrl';
|
import { useAssetUrl } from '../useAssetImageUrl';
|
||||||
|
|
||||||
import styles from './MaterialOverlay.module.css';
|
import styles from './MaterialOverlay.module.css';
|
||||||
@@ -9,43 +10,89 @@ type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
|||||||
|
|
||||||
type MaterialOverlayProps = {
|
type MaterialOverlayProps = {
|
||||||
assetId: AssetId | null;
|
assetId: AssetId | null;
|
||||||
rotationDeg?: 0 | 90 | 180 | 270;
|
|
||||||
layout: MaterialsOverlayLayout;
|
layout: MaterialsOverlayLayout;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
||||||
showClose?: boolean;
|
showClose?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
closeLabel?: string;
|
closeLabel?: string;
|
||||||
|
rotateLabel?: string;
|
||||||
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
|
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
|
||||||
onZoomAt?: (nx: number, ny: number) => void;
|
onZoomAt?: (nx: number, ny: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function RotateIcon() {
|
||||||
|
return (
|
||||||
|
<svg className={styles.frameRotateSvg} viewBox="0 0 24 24" width="16" height="16" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M20 12a8 8 0 1 1-2.2-5.4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path d="M20 4v5h-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function nextBaseSize(
|
function nextBaseSize(
|
||||||
viewW: number,
|
viewW: number,
|
||||||
viewH: number,
|
viewH: number,
|
||||||
naturalW: number,
|
naturalW: number,
|
||||||
naturalH: number,
|
naturalH: number,
|
||||||
rotationDeg: number,
|
|
||||||
): { w: number; h: number } {
|
): { w: number; h: number } {
|
||||||
const swapped = rotationDeg === 90 || rotationDeg === 270;
|
if (naturalW <= 0 || naturalH <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 };
|
||||||
const iw = swapped ? naturalH : naturalW;
|
|
||||||
const ih = swapped ? naturalW : naturalH;
|
|
||||||
if (iw <= 0 || ih <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 };
|
|
||||||
const maxW = viewW * 0.92;
|
const maxW = viewW * 0.92;
|
||||||
const maxH = viewH * 0.88;
|
const maxH = viewH * 0.88;
|
||||||
const fit = Math.min(maxW / iw, maxH / ih);
|
const fit = Math.min(maxW / naturalW, maxH / naturalH);
|
||||||
return { w: iw * fit, h: ih * fit };
|
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({
|
export function MaterialOverlay({
|
||||||
assetId,
|
assetId,
|
||||||
rotationDeg = 0,
|
|
||||||
layout,
|
layout,
|
||||||
editable = false,
|
editable = false,
|
||||||
zoomTool = null,
|
zoomTool = null,
|
||||||
showClose = false,
|
showClose = false,
|
||||||
onClose,
|
onClose,
|
||||||
closeLabel = 'Close',
|
closeLabel = 'Close',
|
||||||
|
rotateLabel = 'Rotate',
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
onZoomAt,
|
onZoomAt,
|
||||||
}: MaterialOverlayProps) {
|
}: MaterialOverlayProps) {
|
||||||
@@ -58,13 +105,20 @@ export function MaterialOverlay({
|
|||||||
| {
|
| {
|
||||||
mode: 'resize';
|
mode: 'resize';
|
||||||
corner: Corner;
|
corner: Corner;
|
||||||
startX: number;
|
|
||||||
startY: number;
|
|
||||||
origin: MaterialsOverlayLayout;
|
origin: MaterialsOverlayLayout;
|
||||||
baseW: number;
|
baseW: number;
|
||||||
baseH: number;
|
baseH: number;
|
||||||
viewW: number;
|
viewW: number;
|
||||||
viewH: number;
|
viewH: number;
|
||||||
|
centerClientX: number;
|
||||||
|
centerClientY: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: 'rotate';
|
||||||
|
origin: MaterialsOverlayLayout;
|
||||||
|
startPointerAngle: number;
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
}
|
}
|
||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -85,15 +139,13 @@ export function MaterialOverlay({
|
|||||||
const zoomCursor =
|
const zoomCursor =
|
||||||
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
|
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
|
||||||
|
|
||||||
const base = nextBaseSize(view.w, view.h, natural.w, natural.h, rotationDeg);
|
const effectiveLayout = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
|
||||||
const w = base.w * layout.scale;
|
const rotationDeg = effectiveLayout.rotationDeg ?? 0;
|
||||||
const h = base.h * layout.scale;
|
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
|
||||||
const left = layout.cx * view.w - w / 2;
|
const w = base.w * effectiveLayout.scale;
|
||||||
const top = layout.cy * view.h - h / 2;
|
const h = base.h * effectiveLayout.scale;
|
||||||
// Рамка — AABB; до CSS-rotate картинка имеет «натуральную» ориентацию.
|
const left = effectiveLayout.cx * view.w - w / 2;
|
||||||
const swapped = rotationDeg === 90 || rotationDeg === 270;
|
const top = effectiveLayout.cy * view.h - h / 2;
|
||||||
const contentW = swapped ? h : w;
|
|
||||||
const contentH = swapped ? w : h;
|
|
||||||
|
|
||||||
const toNorm = (clientX: number, clientY: number) => {
|
const toNorm = (clientX: number, clientY: number) => {
|
||||||
const root = rootRef.current;
|
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 onPointerMove = (e: PointerEvent) => {
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (!drag || !onLayoutChange) return;
|
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;
|
const root = rootRef.current;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const r = root.getBoundingClientRect();
|
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') {
|
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({
|
onLayoutChange({
|
||||||
...drag.origin,
|
...drag.origin,
|
||||||
cx: drag.origin.cx + dx,
|
cx: drag.origin.cx + dx,
|
||||||
@@ -123,13 +196,13 @@ export function MaterialOverlay({
|
|||||||
return;
|
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 originW = baseW * origin.scale;
|
||||||
const originH = baseH * origin.scale;
|
const originH = baseH * origin.scale;
|
||||||
const originLeft = origin.cx * viewW - originW / 2;
|
const originLeft = -originW / 2;
|
||||||
const originTop = origin.cy * viewH - originH / 2;
|
const originTop = -originH / 2;
|
||||||
const originRight = originLeft + originW;
|
const originRight = originW / 2;
|
||||||
const originBottom = originTop + originH;
|
const originBottom = originH / 2;
|
||||||
|
|
||||||
let anchorX = originLeft;
|
let anchorX = originLeft;
|
||||||
let anchorY = originTop;
|
let anchorY = originTop;
|
||||||
@@ -144,10 +217,15 @@ export function MaterialOverlay({
|
|||||||
anchorY = originTop;
|
anchorY = originTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerX = e.clientX - r.left;
|
const local = screenToLocal(
|
||||||
const pointerY = e.clientY - r.top;
|
e.clientX,
|
||||||
const newW = Math.max(8, Math.abs(pointerX - anchorX));
|
e.clientY,
|
||||||
const newH = Math.max(8, Math.abs(pointerY - anchorY));
|
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 nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH));
|
||||||
const ww = baseW * nextScale;
|
const ww = baseW * nextScale;
|
||||||
const hh = baseH * nextScale;
|
const hh = baseH * nextScale;
|
||||||
@@ -165,9 +243,14 @@ export function MaterialOverlay({
|
|||||||
nextTop = anchorY;
|
nextTop = anchorY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localCenterX = nextLeft + ww / 2;
|
||||||
|
const localCenterY = nextTop + hh / 2;
|
||||||
|
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
|
||||||
|
|
||||||
onLayoutChange({
|
onLayoutChange({
|
||||||
cx: (nextLeft + ww / 2) / Math.max(1, viewW),
|
...origin,
|
||||||
cy: (nextTop + hh / 2) / Math.max(1, viewH),
|
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
|
||||||
|
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
|
||||||
scale: nextScale,
|
scale: nextScale,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -182,19 +265,42 @@ export function MaterialOverlay({
|
|||||||
if (!editable || !onLayoutChange || zoomTool) return;
|
if (!editable || !onLayoutChange || zoomTool) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dragRef.current =
|
if (mode === 'move') {
|
||||||
mode === 'move'
|
dragRef.current = {
|
||||||
? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } }
|
mode: 'move',
|
||||||
: {
|
|
||||||
mode: 'resize',
|
|
||||||
corner: corner ?? 'se',
|
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
startY: e.clientY,
|
startY: e.clientY,
|
||||||
origin: { ...layout },
|
origin: { ...effectiveLayout },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const center = layoutCenterClient();
|
||||||
|
dragRef.current = {
|
||||||
|
mode: 'resize',
|
||||||
|
corner: corner ?? 'se',
|
||||||
|
origin: { ...effectiveLayout },
|
||||||
baseW: base.w,
|
baseW: base.w,
|
||||||
baseH: base.h,
|
baseH: base.h,
|
||||||
viewW: view.w,
|
viewW: view.w,
|
||||||
viewH: view.h,
|
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('pointermove', onPointerMove);
|
||||||
window.addEventListener('pointerup', endDrag);
|
window.addEventListener('pointerup', endDrag);
|
||||||
@@ -220,7 +326,14 @@ export function MaterialOverlay({
|
|||||||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : '']
|
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : '']
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.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) => {
|
onPointerDown={(e) => {
|
||||||
if (zoomTool) return;
|
if (zoomTool) return;
|
||||||
startDrag(e, 'move');
|
startDrag(e, 'move');
|
||||||
@@ -232,9 +345,9 @@ export function MaterialOverlay({
|
|||||||
alt=""
|
alt=""
|
||||||
draggable={false}
|
draggable={false}
|
||||||
style={{
|
style={{
|
||||||
width: contentW,
|
width: w,
|
||||||
height: contentH,
|
height: h,
|
||||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
transform: 'translate(-50%, -50%)',
|
||||||
}}
|
}}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
@@ -252,6 +365,17 @@ export function MaterialOverlay({
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
|
{editable && !zoomTool && onLayoutChange ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.frameRotate}
|
||||||
|
aria-label={rotateLabel}
|
||||||
|
title={rotateLabel}
|
||||||
|
onPointerDown={startRotate}
|
||||||
|
>
|
||||||
|
<RotateIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{showClose ? (
|
{showClose ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type MaterialsOverlayLayout = {
|
|||||||
cy: number;
|
cy: number;
|
||||||
/** Масштаб относительно «базового contain» (1 = по умолчанию). */
|
/** Масштаб относительно «базового contain» (1 = по умолчанию). */
|
||||||
scale: number;
|
scale: number;
|
||||||
|
/** Свободный угол поворота на сессии, градусы. */
|
||||||
|
rotationDeg: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MaterialsZoomTool = 'zoomIn' | 'zoomOut' | null;
|
export type MaterialsZoomTool = 'zoomIn' | 'zoomOut' | null;
|
||||||
@@ -24,21 +26,30 @@ export const DEFAULT_MATERIALS_OVERLAY_LAYOUT: MaterialsOverlayLayout = {
|
|||||||
cx: 0.5,
|
cx: 0.5,
|
||||||
cy: 0.5,
|
cy: 0.5,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
|
rotationDeg: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MaterialsOverlayEvent =
|
export type MaterialsOverlayEvent =
|
||||||
| { kind: 'show'; materialId: MaterialId }
|
| { kind: 'show'; materialId: MaterialId; rotationDeg?: number }
|
||||||
| { kind: 'hide' }
|
| { kind: 'hide' }
|
||||||
| { kind: 'toggle'; materialId: MaterialId }
|
| { kind: 'toggle'; materialId: MaterialId; rotationDeg?: number }
|
||||||
| { kind: 'layout.set'; layout: MaterialsOverlayLayout }
|
| { kind: 'layout.set'; layout: MaterialsOverlayLayout }
|
||||||
| { kind: 'zoomTool.set'; tool: MaterialsZoomTool }
|
| { kind: 'zoomTool.set'; tool: MaterialsZoomTool }
|
||||||
| { kind: 'zoomAt'; nx: number; ny: number };
|
| { kind: 'zoomAt'; nx: number; ny: number };
|
||||||
|
|
||||||
|
/** Нормализация угла в диапазон [0, 360). */
|
||||||
|
export function normalizeMaterialsRotation(deg: number): number {
|
||||||
|
if (!Number.isFinite(deg)) return 0;
|
||||||
|
const n = deg % 360;
|
||||||
|
return n < 0 ? n + 360 : n;
|
||||||
|
}
|
||||||
|
|
||||||
export function clampMaterialsLayout(layout: MaterialsOverlayLayout): MaterialsOverlayLayout {
|
export function clampMaterialsLayout(layout: MaterialsOverlayLayout): MaterialsOverlayLayout {
|
||||||
return {
|
return {
|
||||||
cx: Math.min(1.2, Math.max(-0.2, layout.cx)),
|
cx: Math.min(1.2, Math.max(-0.2, layout.cx)),
|
||||||
cy: Math.min(1.2, Math.max(-0.2, layout.cy)),
|
cy: Math.min(1.2, Math.max(-0.2, layout.cy)),
|
||||||
scale: Math.min(8, Math.max(0.15, layout.scale)),
|
scale: Math.min(8, Math.max(0.15, layout.scale)),
|
||||||
|
rotationDeg: normalizeMaterialsRotation(layout.rotationDeg ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user