feat(editor): DnD сцен в список, порядок списка и правки импорта
Можно перетащить изображения/видео в колонку сцен: создаются новые сцены с превью и названием из файла, с полноэкранным прогрессом и отчётом о пропущенных файлах. Добавлен ручной порядок сцен в списке (sceneListOrder) с сохранением в проект; перетаскивание карточек отделено от импорта файлов. Во время сортировки снова работает скролл колесом. Исправлен залипающий disabled у «+ Новая сцена» после создания сцены. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -27,10 +27,13 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import {
|
||||
filterAudioFilePaths,
|
||||
partitionSceneMediaDrops,
|
||||
pickFirstPreviewFilePath,
|
||||
sceneTitleFromMediaPath,
|
||||
useFileDropZone,
|
||||
} from './fileDrop';
|
||||
|
||||
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
|
||||
import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport';
|
||||
import {
|
||||
buildSceneResolutionsForImport,
|
||||
@@ -142,6 +145,57 @@ export function EditorApp() {
|
||||
);
|
||||
const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice });
|
||||
const renameFromPickerRef = useRef(false);
|
||||
/** Синхронно на dragStart, чтобы файловый drop колонки не успел сработать. */
|
||||
const draggingListSceneIdRef = useRef<SceneId | null>(null);
|
||||
|
||||
const showSceneDropSkippedNotice = useCallback(
|
||||
(rejected: { name: string; reason: 'unsupported' | 'no_path' }[]) => {
|
||||
if (rejected.length === 0) return;
|
||||
const reasonText = (reason: 'unsupported' | 'no_path') =>
|
||||
reason === 'no_path' ? t('scenes.dropSkippedNoPath') : t('scenes.dropSkippedUnsupported');
|
||||
const lines = rejected.map((r) => `• ${r.name} — ${reasonText(r.reason)}`);
|
||||
setAppNotice({
|
||||
title: t('scenes.dropSkippedTitle'),
|
||||
message: `${t('scenes.dropSkippedIntro')}\n${lines.join('\n')}`,
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const onScenesColumnDrop = useCallback(
|
||||
(entries: { path: string; name: string }[]) => {
|
||||
if (!state.project || state.creatingScene || state.sceneBatchImport) return;
|
||||
const { accepted, rejected } = partitionSceneMediaDrops(entries);
|
||||
if (accepted.length === 0) {
|
||||
showSceneDropSkippedNotice(rejected);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
await actions.createScenesFromMediaPaths(
|
||||
accepted.map((entry) => ({
|
||||
filePath: entry.path,
|
||||
title: sceneTitleFromMediaPath(entry.path),
|
||||
})),
|
||||
);
|
||||
showSceneDropSkippedNotice(rejected);
|
||||
} catch (e) {
|
||||
setAppNotice({
|
||||
title: t('common.error'),
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[actions, showSceneDropSkippedNotice, state.creatingScene, state.project, state.sceneBatchImport, t],
|
||||
);
|
||||
|
||||
const scenesColumnDrop = useFileDropZone({
|
||||
disabled: !state.project || state.creatingScene || state.sceneBatchImport !== null,
|
||||
isBlocked: () => draggingListSceneIdRef.current !== null,
|
||||
onDropEntries: onScenesColumnDrop,
|
||||
});
|
||||
|
||||
const sceneCardById = useStableSceneCardById(state.project);
|
||||
const graphUi = useMemo<SceneGraphUiStrings>(
|
||||
() => ({
|
||||
@@ -177,6 +231,25 @@ export function EditorApp() {
|
||||
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
|
||||
const [sceneListDrop, setSceneListDrop] = useState<{
|
||||
targetId: SceneId;
|
||||
place: 'before' | 'after';
|
||||
} | null>(null);
|
||||
const [draggingListSceneId, setDraggingListSceneId] = useState<SceneId | null>(null);
|
||||
const sceneListScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingListSceneId) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const el = sceneListScrollRef.current;
|
||||
if (!el) return;
|
||||
// Во время HTML5-drag браузер глотает обычный scroll — крутим вручную.
|
||||
e.preventDefault();
|
||||
el.scrollTop += e.deltaY;
|
||||
};
|
||||
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
return () => window.removeEventListener('wheel', onWheel, { capture: true });
|
||||
}, [draggingListSceneId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedGraphNodeId(null);
|
||||
@@ -192,13 +265,10 @@ export function EditorApp() {
|
||||
const scenes = useMemo<SceneCard[]>(() => {
|
||||
const p = state.project;
|
||||
if (!p) return [];
|
||||
const createdAtSortKey = (sceneId: string): number => {
|
||||
// sceneId создаётся как `${prefix}_${rand}_${Date.now().toString(16)}`
|
||||
const last = sceneId.split('_').at(-1) ?? '';
|
||||
const n = Number.parseInt(last, 16);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
return Object.values(p.scenes)
|
||||
const order = reconcileSceneListOrder(p.scenes, p.sceneListOrder);
|
||||
return order
|
||||
.map((id) => p.scenes[id])
|
||||
.filter((s): s is NonNullable<typeof s> => Boolean(s))
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
@@ -208,10 +278,11 @@ export function EditorApp() {
|
||||
previewAssetType: s.previewAssetType,
|
||||
previewVideoAutostart: s.previewVideoAutostart,
|
||||
previewRotationDeg: s.previewRotationDeg,
|
||||
}))
|
||||
.sort((a, b) => createdAtSortKey(b.id) - createdAtSortKey(a.id));
|
||||
}));
|
||||
}, [state.project, state.selectedSceneId]);
|
||||
|
||||
const sceneListReorderEnabled = query.trim().length === 0;
|
||||
|
||||
const filtered = useMemo(
|
||||
() => scenes.filter((s) => s.title.toLowerCase().includes(query.trim().toLowerCase())),
|
||||
[query, scenes],
|
||||
@@ -532,6 +603,51 @@ export function EditorApp() {
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{state.sceneBatchImport
|
||||
? createPortal(
|
||||
<div className={styles.editorLockOverlay} role="dialog" aria-label={t('scenes.batchTitle')}>
|
||||
<div className={styles.progressModal}>
|
||||
<div className={styles.progressTitle}>{t('scenes.batchTitle')}</div>
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{
|
||||
width: `${String(
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
(state.sceneBatchImport.current / Math.max(1, state.sceneBatchImport.total)) *
|
||||
100,
|
||||
),
|
||||
),
|
||||
),
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.progressMeta}>
|
||||
<div>
|
||||
{t('scenes.batchProgress')
|
||||
.replace('{current}', String(state.sceneBatchImport.current))
|
||||
.replace('{total}', String(state.sceneBatchImport.total))}
|
||||
{state.sceneBatchImport.fileName
|
||||
? `: ${state.sceneBatchImport.fileName}`
|
||||
: ''}
|
||||
</div>
|
||||
<div>
|
||||
{Math.round(
|
||||
(state.sceneBatchImport.current / Math.max(1, state.sceneBatchImport.total)) * 100,
|
||||
)}
|
||||
%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
<LayoutShell
|
||||
bodyOverlay={bodyOverlay}
|
||||
topBar={
|
||||
@@ -658,31 +774,87 @@ export function EditorApp() {
|
||||
</div>
|
||||
}
|
||||
left={
|
||||
<div className={styles.editorSidebar}>
|
||||
<div
|
||||
className={[
|
||||
styles.editorSidebar,
|
||||
state.project && scenesColumnDrop.dragOver ? styles.editorSidebarDragOver : '',
|
||||
].join(' ')}
|
||||
onDragEnter={state.project ? scenesColumnDrop.onDragEnter : undefined}
|
||||
onDragLeave={state.project ? scenesColumnDrop.onDragLeave : undefined}
|
||||
onDragOver={state.project ? scenesColumnDrop.onDragOver : undefined}
|
||||
onDrop={state.project ? scenesColumnDrop.onDrop : undefined}
|
||||
>
|
||||
{state.project && scenesColumnDrop.dragOver ? (
|
||||
<div className={styles.dropHintOverlay}>{t('scenes.dropHint')}</div>
|
||||
) : null}
|
||||
{state.project ? (
|
||||
<>
|
||||
<div className={styles.gridTools}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('scenes.search')} />
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={state.creatingScene}
|
||||
disabled={state.creatingScene || state.sceneBatchImport !== null}
|
||||
onClick={() => void actions.createScene()}
|
||||
>
|
||||
{t('scenes.new')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.spacer14} />
|
||||
<div className={styles.sidebarScroll}>
|
||||
<div className={styles.sidebarScroll} ref={sceneListScrollRef}>
|
||||
<div className={styles.sceneListGrid}>
|
||||
{filtered.map((s) => (
|
||||
<SceneListCard
|
||||
key={s.id}
|
||||
scene={s}
|
||||
reorderEnabled={sceneListReorderEnabled}
|
||||
listDragActive={draggingListSceneId !== null}
|
||||
dropPlace={
|
||||
sceneListDrop?.targetId === s.id ? sceneListDrop.place : null
|
||||
}
|
||||
isDragging={draggingListSceneId === s.id}
|
||||
onSelect={() => {
|
||||
setSelectedGraphNodeId(null);
|
||||
void actions.selectScene(s.id);
|
||||
}}
|
||||
onDeleteScene={(id) => void actions.deleteScene(id)}
|
||||
onDragListStart={(id) => {
|
||||
draggingListSceneIdRef.current = id;
|
||||
setDraggingListSceneId(id);
|
||||
}}
|
||||
onDragListEnd={() => {
|
||||
draggingListSceneIdRef.current = null;
|
||||
setDraggingListSceneId(null);
|
||||
setSceneListDrop(null);
|
||||
}}
|
||||
onDragListOver={(targetId, place) => {
|
||||
if (!sceneListReorderEnabled || !draggingListSceneIdRef.current) return;
|
||||
if (draggingListSceneIdRef.current === targetId) {
|
||||
setSceneListDrop(null);
|
||||
return;
|
||||
}
|
||||
setSceneListDrop((cur) =>
|
||||
cur?.targetId === targetId && cur.place === place
|
||||
? cur
|
||||
: { targetId, place },
|
||||
);
|
||||
}}
|
||||
onDropListReorder={(draggedId, targetId, place) => {
|
||||
if (!sceneListReorderEnabled || !state.project) {
|
||||
draggingListSceneIdRef.current = null;
|
||||
setSceneListDrop(null);
|
||||
setDraggingListSceneId(null);
|
||||
return;
|
||||
}
|
||||
const order = reconcileSceneListOrder(
|
||||
state.project.scenes,
|
||||
state.project.sceneListOrder,
|
||||
);
|
||||
const next = moveSceneInListOrder(order, draggedId, targetId, place);
|
||||
draggingListSceneIdRef.current = null;
|
||||
setSceneListDrop(null);
|
||||
setDraggingListSceneId(null);
|
||||
void actions.setSceneListOrder(next);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1466,7 +1638,7 @@ function SimpleMessageModal({ open, title, message, onClose }: SimpleMessageModa
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.muted}>{message}</div>
|
||||
<div className={[styles.muted, styles.noticeMessage].join(' ')}>{message}</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.close')}</Button>
|
||||
@@ -2275,11 +2447,31 @@ function SceneInspector({
|
||||
|
||||
type SceneListCardProps = {
|
||||
scene: SceneCard;
|
||||
reorderEnabled: boolean;
|
||||
listDragActive: boolean;
|
||||
dropPlace: 'before' | 'after' | null;
|
||||
isDragging: boolean;
|
||||
onSelect: () => void;
|
||||
onDeleteScene: (sceneId: SceneId) => void;
|
||||
onDragListStart: (sceneId: SceneId) => void;
|
||||
onDragListEnd: () => void;
|
||||
onDragListOver: (targetId: SceneId, place: 'before' | 'after') => void;
|
||||
onDropListReorder: (draggedId: SceneId, targetId: SceneId, place: 'before' | 'after') => void;
|
||||
};
|
||||
|
||||
function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
|
||||
function SceneListCard({
|
||||
scene,
|
||||
reorderEnabled,
|
||||
listDragActive,
|
||||
dropPlace,
|
||||
isDragging,
|
||||
onSelect,
|
||||
onDeleteScene,
|
||||
onDragListStart,
|
||||
onDragListEnd,
|
||||
onDragListOver,
|
||||
onDropListReorder,
|
||||
}: SceneListCardProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const thumbUrl = useAssetUrl(scene.previewThumbAssetId);
|
||||
const previewUrl = useAssetUrl(scene.previewAssetId);
|
||||
@@ -2305,14 +2497,45 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
|
||||
};
|
||||
}, [menu]);
|
||||
|
||||
const cardClass = [styles.sceneCard, scene.active ? styles.sceneCardActive : ''].filter(Boolean).join(' ');
|
||||
const cardClass = [
|
||||
styles.sceneCard,
|
||||
scene.active ? styles.sceneCardActive : '',
|
||||
isDragging ? styles.sceneCardDragging : '',
|
||||
dropPlace === 'before' ? styles.sceneCardDropBefore : '',
|
||||
dropPlace === 'after' ? styles.sceneCardDropAfter : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
className={cardClass}
|
||||
onDragStart={(e) => {
|
||||
// Сразу блокируем файловый drop колонки (до React re-render).
|
||||
onDragListStart(scene.id);
|
||||
e.dataTransfer.setData(DND_SCENE_ID_MIME, scene.id);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.effectAllowed = reorderEnabled ? 'copyMove' : 'copy';
|
||||
}}
|
||||
onDragEnd={() => onDragListEnd()}
|
||||
onDragOver={(e) => {
|
||||
if (!reorderEnabled || !listDragActive) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
|
||||
onDragListOver(scene.id, place);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!reorderEnabled || !listDragActive) return;
|
||||
const draggedId = e.dataTransfer.getData(DND_SCENE_ID_MIME);
|
||||
if (!draggedId) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
|
||||
onDropListReorder(draggedId as SceneId, scene.id, place);
|
||||
}}
|
||||
onClick={onSelect}
|
||||
role="button"
|
||||
@@ -2349,6 +2572,7 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
draggable={false}
|
||||
className={styles.sceneThumbVideo}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
|
||||
Reference in New Issue
Block a user