feat(scenes): add scene darkness reveal and fix project reopen crash

Add darkenScene with Opening brush in presentation, persist reveal strokes per scene during a session, and close projects properly on return to home. Harden dnd asset streaming to avoid Windows main-process crashes when reopening projects.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-02 19:41:26 +08:00
parent d54e9ed02d
commit 4631a1bece
19 changed files with 526 additions and 11 deletions
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneDarknessStore } from './sceneDarknessStore';
void test('SceneDarknessStore: сохраняет штрихи при переключении сцен', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
},
});
store.switchScene('scene_b', true);
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_a', true);
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 's1');
});
void test('SceneDarknessStore: resetSession очищает кэш', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.2, y: 0.2, tMs: 100 }],
radiusN: 0.08,
},
});
store.resetSession();
store.switchScene('scene_a', true);
assert.equal(store.getState().strokes.length, 0);
});
void test('SceneDarknessStore: draft синхронизируется и сбрасывается при commit', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'draft.set',
draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05 },
});
assert.ok(store.getState().draft);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.1, y: 0.1, tMs: 1 }],
radiusN: 0.05,
},
});
assert.equal(store.getState().draft, null);
assert.equal(store.getState().strokes.length, 1);
});
+80
View File
@@ -0,0 +1,80 @@
import type { SceneDarknessEvent, SceneDarknessRevealStroke, SceneDarknessState } from '../../shared/types';
function emptyState(): SceneDarknessState {
return {
revision: 1,
enabled: false,
cacheKey: null,
strokes: [],
draft: null,
};
}
export class SceneDarknessStore {
private state: SceneDarknessState = emptyState();
/** Кэш раскрытых областей по ключу (graphNodeId или sceneId) на время сессии показа. */
private cache = new Map<string, SceneDarknessRevealStroke[]>();
private currentKey: string | null = null;
getState(): SceneDarknessState {
return { ...this.state, strokes: [...this.state.strokes] };
}
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
resetSession(): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
}
/**
* Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены.
*/
switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, [...this.state.strokes]);
}
this.currentKey = cacheKey;
const strokes =
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : [];
this.state = {
revision: this.state.revision + 1,
enabled,
cacheKey,
strokes,
draft: null,
};
return this.getState();
}
dispatch(event: SceneDarknessEvent): SceneDarknessState {
if (!this.state.enabled) return this.getState();
switch (event.kind) {
case 'draft.set':
this.state = {
...this.state,
revision: this.state.revision + 1,
draft: event.draft,
};
return this.getState();
case 'stroke.add': {
const strokes = [...this.state.strokes, event.stroke];
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return this.getState();
}
}
}
}