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:
@@ -279,7 +279,9 @@ export function openMultiWindow() {
|
|||||||
presentation.maximize();
|
presentation.maximize();
|
||||||
}
|
}
|
||||||
if (!windows.has('control')) {
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -462,6 +462,46 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.videoCover {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export function EditorApp() {
|
|||||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
|
const [previewBusy, setPreviewBusy] = useState(false);
|
||||||
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
|
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
|
||||||
const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
|
const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
|
||||||
const [eulaModalOpen, setEulaModalOpen] = useState(false);
|
const [eulaModalOpen, setEulaModalOpen] = useState(false);
|
||||||
@@ -480,6 +481,7 @@ export function EditorApp() {
|
|||||||
previewAssetType={sc?.previewAssetType ?? null}
|
previewAssetType={sc?.previewAssetType ?? null}
|
||||||
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
||||||
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
||||||
|
previewBusy={previewBusy}
|
||||||
mediaAssets={sceneMediaAssets}
|
mediaAssets={sceneMediaAssets}
|
||||||
audioRefs={sceneAudioRefs}
|
audioRefs={sceneAudioRefs}
|
||||||
onAudioRefsChange={(next) =>
|
onAudioRefsChange={(next) =>
|
||||||
@@ -492,7 +494,18 @@ export function EditorApp() {
|
|||||||
onDescriptionChange={(description) =>
|
onDescriptionChange={(description) =>
|
||||||
void actions.updateScene(sid, { 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)}
|
onClearPreview={() => void actions.clearScenePreview(sid)}
|
||||||
onRotatePreview={(previewRotationDeg) =>
|
onRotatePreview={(previewRotationDeg) =>
|
||||||
void actions.updateScene(sid, { previewRotationDeg })
|
void actions.updateScene(sid, { previewRotationDeg })
|
||||||
@@ -1040,6 +1053,7 @@ type SceneInspectorProps = {
|
|||||||
previewAssetType: 'image' | 'video' | null;
|
previewAssetType: 'image' | 'video' | null;
|
||||||
previewVideoAutostart: boolean;
|
previewVideoAutostart: boolean;
|
||||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||||
|
previewBusy: boolean;
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
audioRefs: SceneAudioRef[];
|
audioRefs: SceneAudioRef[];
|
||||||
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||||
@@ -1145,6 +1159,7 @@ function SceneInspector({
|
|||||||
previewAssetType,
|
previewAssetType,
|
||||||
previewVideoAutostart,
|
previewVideoAutostart,
|
||||||
previewRotationDeg,
|
previewRotationDeg,
|
||||||
|
previewBusy,
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
audioRefs,
|
audioRefs,
|
||||||
onAudioRefsChange,
|
onAudioRefsChange,
|
||||||
@@ -1188,6 +1203,14 @@ function SceneInspector({
|
|||||||
) : (
|
) : (
|
||||||
<div className={styles.previewEmpty}>Превью не задано</div>
|
<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>
|
||||||
<div className={styles.actionsRow}>
|
<div className={styles.actionsRow}>
|
||||||
<Button variant="primary" onClick={onImportPreview}>
|
<Button variant="primary" onClick={onImportPreview}>
|
||||||
|
|||||||
@@ -20,8 +20,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cardActive {
|
.cardActive {
|
||||||
border-color: var(--graph-node-active-border);
|
border-color: rgba(167, 139, 250, 0.95);
|
||||||
box-shadow: 0 25px 50px -12px rgba(139, 92, 246, 0.1);
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(167, 139, 250, 0.35),
|
||||||
|
0 25px 50px -12px rgba(167, 139, 250, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewShell {
|
.previewShell {
|
||||||
|
|||||||
@@ -365,16 +365,36 @@ function SceneGraphCanvas({
|
|||||||
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
|
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
|
||||||
|
|
||||||
const desiredEdges = useMemo<Edge[]>(() => {
|
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) => ({
|
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,
|
id: e.id,
|
||||||
source: e.sourceGraphNodeId,
|
source: e.sourceGraphNodeId,
|
||||||
target: e.targetGraphNodeId,
|
target: e.targetGraphNodeId,
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: false,
|
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 [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user