feat: multi-material overlays, presentation guides, and release prebuilds
Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -946,6 +946,33 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actionsRowHalf {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowVideoChecks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -1096,6 +1096,7 @@ export function EditorApp() {
|
||||
previewAssetId={sc?.previewAssetId ?? null}
|
||||
previewAssetType={sc?.previewAssetType ?? null}
|
||||
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
||||
previewVideoLoop={sc?.settings.loopVideo ?? false}
|
||||
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
||||
darkenScene={sc?.darkenScene ?? false}
|
||||
previewBusy={previewBusy}
|
||||
@@ -1108,6 +1109,9 @@ export function EditorApp() {
|
||||
onPreviewVideoAutostartChange={(next) =>
|
||||
void actions.updateScene(sid, { previewVideoAutostart: next })
|
||||
}
|
||||
onPreviewVideoLoopChange={(next) =>
|
||||
void actions.updateScene(sid, { settings: { loopVideo: next } })
|
||||
}
|
||||
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
|
||||
onTitleChange={(title) => void actions.updateScene(sid, { title })}
|
||||
onDescriptionChange={(description) =>
|
||||
@@ -2322,6 +2326,7 @@ type SceneInspectorProps = {
|
||||
previewAssetId: AssetId | null;
|
||||
previewAssetType: 'image' | 'video' | null;
|
||||
previewVideoAutostart: boolean;
|
||||
previewVideoLoop: boolean;
|
||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||
darkenScene: boolean;
|
||||
previewBusy: boolean;
|
||||
@@ -2330,6 +2335,7 @@ type SceneInspectorProps = {
|
||||
audioRefs: SceneAudioRef[];
|
||||
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||
onPreviewVideoAutostartChange: (next: boolean) => void;
|
||||
onPreviewVideoLoopChange: (next: boolean) => void;
|
||||
onDarkenSceneChange: (next: boolean) => void;
|
||||
onTitleChange: (v: string) => void;
|
||||
onDescriptionChange: (v: string) => void;
|
||||
@@ -2456,6 +2462,7 @@ function SceneInspector({
|
||||
previewAssetId,
|
||||
previewAssetType,
|
||||
previewVideoAutostart,
|
||||
previewVideoLoop,
|
||||
previewRotationDeg,
|
||||
darkenScene,
|
||||
previewBusy,
|
||||
@@ -2464,6 +2471,7 @@ function SceneInspector({
|
||||
audioRefs,
|
||||
onAudioRefsChange,
|
||||
onPreviewVideoAutostartChange,
|
||||
onPreviewVideoLoopChange,
|
||||
onDarkenSceneChange,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
@@ -2578,7 +2586,7 @@ function SceneInspector({
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={previewVideoAutostart}
|
||||
loop
|
||||
loop={previewVideoLoop}
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
/>
|
||||
@@ -2595,12 +2603,14 @@ function SceneInspector({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.actionsRow}>
|
||||
<div className={styles.actionsRowHalf}>
|
||||
<Button variant="primary" disabled={previewBusy} onClick={onImportPreview}>
|
||||
{previewAssetId ? t('scene.change') : t('campaign.upload')}
|
||||
</Button>
|
||||
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : null}
|
||||
{previewAssetId && previewAssetType === 'video' ? (
|
||||
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : <span aria-hidden />}
|
||||
</div>
|
||||
{previewAssetId && previewAssetType === 'video' ? (
|
||||
<div className={styles.actionsRowVideoChecks}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -2609,8 +2619,19 @@ function SceneInspector({
|
||||
/>
|
||||
<span className={styles.spanSm}>{t('scene.autostart')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={previewVideoLoop}
|
||||
onChange={(e) => onPreviewVideoLoopChange(e.target.checked)}
|
||||
/>
|
||||
<span className={styles.spanSm}>{t('campaign.loop')}</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
@@ -2619,10 +2640,6 @@ function SceneInspector({
|
||||
>
|
||||
{t('scene.rotate')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
@@ -2879,7 +2896,6 @@ function SceneListCard({
|
||||
</div>
|
||||
<div className={styles.sceneCardBody}>
|
||||
<div className={styles.sceneCardHeader}>
|
||||
{scene.active ? <div className={styles.badgeCurrent}>{t('sceneCard.current')}</div> : null}
|
||||
<div
|
||||
ref={titleRef}
|
||||
className={styles.sceneCardTitle}
|
||||
|
||||
@@ -15,6 +15,17 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legendHeadRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.legendHeadRow .row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -27,6 +27,8 @@ type Props = {
|
||||
/** Крупная карта (окно «Материалы»). */
|
||||
largeMap?: boolean;
|
||||
onChange: (legend: MaterialLegend) => void;
|
||||
onRotate?: () => void;
|
||||
rotateLabel?: string;
|
||||
};
|
||||
|
||||
type DragMode =
|
||||
@@ -55,6 +57,8 @@ export function MaterialLegendEditor({
|
||||
rotationDeg = 0,
|
||||
largeMap = false,
|
||||
onChange,
|
||||
onRotate,
|
||||
rotateLabel = 'Повернуть',
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend));
|
||||
const [activeItemId, setActiveItemId] = useState<string | null>(null);
|
||||
@@ -404,10 +408,15 @@ export function MaterialLegendEditor({
|
||||
<div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}>
|
||||
{largeMap ? mapBlock : null}
|
||||
|
||||
<label className={styles.row}>
|
||||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>Легенда</span>
|
||||
</label>
|
||||
<div className={styles.legendHeadRow}>
|
||||
{onRotate ? (
|
||||
<Button onClick={onRotate}>{rotateLabel}</Button>
|
||||
) : null}
|
||||
<label className={styles.row}>
|
||||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>Легенда</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.enabled ? (
|
||||
<>
|
||||
|
||||
@@ -19,7 +19,7 @@ export type MaterialsBrowserProps = {
|
||||
mode: 'editor' | 'runtime';
|
||||
selectedId: MaterialId | null;
|
||||
onSelect: (id: MaterialId | null) => void;
|
||||
activeMaterialId?: MaterialId | null;
|
||||
activeMaterialIds?: readonly MaterialId[];
|
||||
onAdd?: () => void;
|
||||
onEdit?: (material: ProjectMaterial) => void;
|
||||
onDelete?: (materialId: MaterialId) => Promise<void>;
|
||||
@@ -40,7 +40,7 @@ export function MaterialsBrowser({
|
||||
mode,
|
||||
selectedId,
|
||||
onSelect,
|
||||
activeMaterialId = null,
|
||||
activeMaterialIds = [],
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -74,6 +74,7 @@ export function MaterialsBrowser({
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [menuFor]);
|
||||
|
||||
const activeSet = useMemo(() => new Set(activeMaterialIds), [activeMaterialIds]);
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return materials;
|
||||
@@ -126,7 +127,7 @@ export function MaterialsBrowser({
|
||||
key={m.id}
|
||||
material={m}
|
||||
selected={m.id === selectedId}
|
||||
active={m.id === activeMaterialId}
|
||||
active={activeSet.has(m.id)}
|
||||
showMenu={mode === 'editor'}
|
||||
dragging={dragId === m.id}
|
||||
dropPlace={dropPlace?.id === m.id ? dropPlace.place : null}
|
||||
@@ -200,6 +201,16 @@ export function MaterialsBrowser({
|
||||
legend={selected.legend}
|
||||
previewUrl={selectedUrl}
|
||||
rotationDeg={selected.rotationDeg ?? 0}
|
||||
rotateLabel={t('scene.rotate')}
|
||||
onRotate={
|
||||
onRotate
|
||||
? () => {
|
||||
const cur = selected.rotationDeg ?? 0;
|
||||
const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
onRotate(selected.id, next);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={(next) => {
|
||||
void onLegendChange(selected.id, next);
|
||||
}}
|
||||
@@ -220,7 +231,7 @@ export function MaterialsBrowser({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selected && onRotate ? (
|
||||
{selected && onRotate && !onLegendChange ? (
|
||||
<div className={matStyles.previewActions}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -33,6 +33,28 @@
|
||||
.browserToolbarRow > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.browserToolbarRow > * > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browserToolbarZoomRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browserToolbarFullBtn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browserToolbarFullBtn > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browserToolbarHint {
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.musicParams {
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
} from '../../../shared/graph/sceneGraphLineage';
|
||||
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
|
||||
import { RotatedImage } from '../../shared/RotatedImage';
|
||||
import { EllipsisText } from '../../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
|
||||
import { useAssetUrl } from '../../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './SceneGraph.module.css';
|
||||
@@ -293,7 +295,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.nodeBody}>
|
||||
<div className={styles.title}>{data.title || ui.untitled}</div>
|
||||
<EllipsisText text={data.title || ui.untitled} className={[styles.title, ellipsisStyles.root].join(' ')} />
|
||||
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParams}>
|
||||
{data.hasAnyAudioLoop ? (
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
translateEditorMessage,
|
||||
type EditorLocale,
|
||||
} from './editorMessages';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
|
||||
type EditorI18nContextValue = {
|
||||
locale: EditorLocale;
|
||||
@@ -36,6 +38,15 @@ export function EditorI18nProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const tag = locale === 'ru' ? 'ru-RU' : 'en-US';
|
||||
try {
|
||||
void getDndApi().invoke(ipcChannels.windows.syncChromeTitles, { localeTag: tag });
|
||||
} catch {
|
||||
// preload ещё не готов (редко при первом кадре)
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
// Другие окна Electron (пульт, материалы) подхватывают смену языка из редактора.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
|
||||
@@ -406,7 +406,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'materials.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'materials.tileMenu': 'Меню материала',
|
||||
'materials.windowEmpty': 'Добавьте материалы в редакторе.',
|
||||
'materials.closeOverlay': 'Закрыть материал',
|
||||
'materials.closeOverlay': 'Закрыть материалы',
|
||||
'materials.rotateOverlay': 'Повернуть',
|
||||
'materials.deleteTitle': 'Удаление материала',
|
||||
'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?',
|
||||
@@ -977,7 +977,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'materials.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'materials.tileMenu': 'Material menu',
|
||||
'materials.windowEmpty': 'Add materials in the editor.',
|
||||
'materials.closeOverlay': 'Close material',
|
||||
'materials.closeOverlay': 'Close materials',
|
||||
'materials.rotateOverlay': 'Rotate',
|
||||
'materials.deleteTitle': 'Delete material',
|
||||
'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?',
|
||||
|
||||
Reference in New Issue
Block a user