feat(editor): highlight edges and show preview import loader

- Highlight all edges connected to selected scene

- Show overlay spinner while uploading/optimizing preview image

- macOS: keep control window independent from presentation

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-23 22:01:07 +08:00
parent 8f8eef53c9
commit a24e87035a
5 changed files with 94 additions and 7 deletions
+3 -1
View File
@@ -279,7 +279,9 @@ export function openMultiWindow() {
presentation.maximize();
}
if (!windows.has('control')) {
createWindow('control', { parent: presentation });
// macOS: parent-child window binding moves child with the parent (unlike Windows behavior we want).
// Keep control window independent on darwin.
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
}
}
+40
View File
@@ -462,6 +462,46 @@
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.previewBusyOverlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
}
.previewBusyModal {
display: grid;
justify-items: center;
gap: 10px;
color: var(--text1);
text-align: center;
}
.previewBusyText {
font-size: var(--text-xs);
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
}
.previewSpinner {
width: 26px;
height: 26px;
border-radius: 999px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: rgba(255, 255, 255, 0.9);
animation: previewSpin 0.9s linear infinite;
}
@keyframes previewSpin {
to {
transform: rotate(360deg);
}
}
.videoCover {
+24 -1
View File
@@ -64,6 +64,7 @@ export function EditorApp() {
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [previewBusy, setPreviewBusy] = useState(false);
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
const [eulaModalOpen, setEulaModalOpen] = useState(false);
@@ -480,6 +481,7 @@ export function EditorApp() {
previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0}
previewBusy={previewBusy}
mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs}
onAudioRefsChange={(next) =>
@@ -492,7 +494,18 @@ export function EditorApp() {
onDescriptionChange={(description) =>
void actions.updateScene(sid, { description })
}
onImportPreview={() => void actions.importScenePreview(sid)}
onImportPreview={() => {
setPreviewBusy(true);
void (async () => {
try {
await actions.importScenePreview(sid);
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
} finally {
setPreviewBusy(false);
}
})();
}}
onClearPreview={() => void actions.clearScenePreview(sid)}
onRotatePreview={(previewRotationDeg) =>
void actions.updateScene(sid, { previewRotationDeg })
@@ -1040,6 +1053,7 @@ type SceneInspectorProps = {
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
previewBusy: boolean;
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
@@ -1145,6 +1159,7 @@ function SceneInspector({
previewAssetType,
previewVideoAutostart,
previewRotationDeg,
previewBusy,
mediaAssets,
audioRefs,
onAudioRefsChange,
@@ -1188,6 +1203,14 @@ function SceneInspector({
) : (
<div className={styles.previewEmpty}>Превью не задано</div>
)}
{previewBusy ? (
<div className={styles.previewBusyOverlay} aria-live="polite">
<div className={styles.previewBusyModal}>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.previewBusyText}>Загрузка и оптимизация изображения</div>
</div>
</div>
) : null}
</div>
<div className={styles.actionsRow}>
<Button variant="primary" onClick={onImportPreview}>
@@ -20,8 +20,10 @@
}
.cardActive {
border-color: var(--graph-node-active-border);
box-shadow: 0 25px 50px -12px rgba(139, 92, 246, 0.1);
border-color: rgba(167, 139, 250, 0.95);
box-shadow:
0 0 0 2px rgba(167, 139, 250, 0.35),
0 25px 50px -12px rgba(167, 139, 250, 0.12);
}
.previewShell {
+23 -3
View File
@@ -365,16 +365,36 @@ function SceneGraphCanvas({
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
const desiredEdges = useMemo<Edge[]>(() => {
const selectedGraphNodeIds = new Set<GraphNodeId>();
if (currentSceneId) {
for (const gn of sceneGraphNodes) {
if (gn.sceneId === currentSceneId) selectedGraphNodeIds.add(gn.id);
}
}
const hasSelection = selectedGraphNodeIds.size > 0;
return sceneGraphEdges.map((e) => ({
...(hasSelection
? {
style:
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
? { stroke: 'rgba(167,139,250,0.95)', strokeWidth: 3 }
: { stroke: 'rgba(255,255,255,0.10)', strokeWidth: 2 },
markerEnd:
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
? { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.95)', strokeWidth: 2 }
: { type: MarkerType.ArrowClosed, color: 'rgba(255,255,255,0.18)', strokeWidth: 2 },
}
: {
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 },
}),
id: e.id,
source: e.sourceGraphNodeId,
target: e.targetGraphNodeId,
type: 'smoothstep',
animated: false,
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 },
}));
}, [sceneGraphEdges]);
}, [currentSceneId, sceneGraphEdges, sceneGraphNodes]);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);