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:
Ivan Fontosh
2026-08-03 11:28:18 +08:00
parent 61446dacfc
commit 2979d06f1c
41 changed files with 1060 additions and 252 deletions
+27
View File
@@ -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;
+27 -11
View File
@@ -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;
+13 -4
View File
@@ -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 ? (
<>
+15 -4
View File
@@ -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 {
+3 -1
View File
@@ -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) => {
+2 -2
View File
@@ -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}”?',