feat(control): инструмент «Маркер» на области видимости
Добавлена кисть маркера на пульте и презентации с настройками в панели эффектов; штрихи привязаны к жёлтой рамке, ластик и очистка работают с отдельным сессионным слоем. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SceneMarkerStore } from './sceneMarkerStore';
|
||||
|
||||
void test('SceneMarkerStore: сохраняет штрихи при переключении сцен', () => {
|
||||
const store = new SceneMarkerStore();
|
||||
store.switchScene('scene_a');
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'm1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
color: '#ff0000',
|
||||
},
|
||||
});
|
||||
|
||||
store.switchScene('scene_b');
|
||||
assert.equal(store.getState().strokes.length, 0);
|
||||
|
||||
store.switchScene('scene_a');
|
||||
assert.equal(store.getState().strokes.length, 1);
|
||||
assert.equal(store.getState().strokes[0]?.id, 'm1');
|
||||
});
|
||||
|
||||
void test('SceneMarkerStore: resetSession очищает кэш', () => {
|
||||
const store = new SceneMarkerStore();
|
||||
store.switchScene('scene_a');
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'm1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.2, y: 0.2, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
color: '#00ff00',
|
||||
},
|
||||
});
|
||||
store.resetSession();
|
||||
store.switchScene('scene_a');
|
||||
assert.equal(store.getState().strokes.length, 0);
|
||||
});
|
||||
|
||||
void test('SceneMarkerStore: strokes.erase удаляет точки под ластиком', () => {
|
||||
const store = new SceneMarkerStore();
|
||||
store.switchScene('scene_a');
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'm1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [
|
||||
{ x: 0.1, y: 0.5, tMs: 100 },
|
||||
{ x: 0.5, y: 0.5, tMs: 101 },
|
||||
{ x: 0.9, y: 0.5, tMs: 102 },
|
||||
],
|
||||
radiusN: 0.04,
|
||||
color: '#ff0000',
|
||||
},
|
||||
});
|
||||
store.dispatch({
|
||||
kind: 'strokes.erase',
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
|
||||
radiusN: 0.06,
|
||||
});
|
||||
const strokes = store.getState().strokes;
|
||||
assert.ok(strokes.length >= 1);
|
||||
assert.ok(strokes.every((s) => s.points.every((p) => Math.abs(p.x - 0.5) > 0.05)));
|
||||
});
|
||||
|
||||
void test('SceneMarkerStore: strokes.clear удаляет все штрихи', () => {
|
||||
const store = new SceneMarkerStore();
|
||||
store.switchScene('scene_a');
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'm1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
color: '#0000ff',
|
||||
},
|
||||
});
|
||||
store.dispatch({ kind: 'strokes.clear' });
|
||||
assert.equal(store.getState().strokes.length, 0);
|
||||
store.switchScene('scene_b');
|
||||
store.switchScene('scene_a');
|
||||
assert.equal(store.getState().strokes.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { applySceneMarkerEraserStroke } from '../../shared/sceneMarkerEraser';
|
||||
import type { SceneMarkerEvent, SceneMarkerState, SceneMarkerStroke } from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneMarkerState {
|
||||
return {
|
||||
revision: 1,
|
||||
cacheKey: null,
|
||||
strokes: [],
|
||||
draft: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStrokeId(prefix: string): string {
|
||||
return `${prefix}_${String(Date.now())}_${String(Math.floor(Math.random() * 1_000_000_000))}`;
|
||||
}
|
||||
|
||||
export class SceneMarkerStore {
|
||||
private state: SceneMarkerState = emptyState();
|
||||
/** Кэш штрихов маркера по ключу (graphNodeId или sceneId) на время сессии показа. */
|
||||
private cache = new Map<string, SceneMarkerStroke[]>();
|
||||
private currentKey: string | null = null;
|
||||
|
||||
getState(): SceneMarkerState {
|
||||
return { ...this.state, strokes: [...this.state.strokes] };
|
||||
}
|
||||
|
||||
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
|
||||
resetSession(): void {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = emptyState();
|
||||
}
|
||||
|
||||
/** Переключение сцены: сохраняем штрихи в кэш и загружаем новую сцену. */
|
||||
switchScene(cacheKey: string | null): SceneMarkerState {
|
||||
if (this.currentKey !== null) {
|
||||
this.cache.set(this.currentKey, [...this.state.strokes]);
|
||||
}
|
||||
this.currentKey = cacheKey;
|
||||
const strokes = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
cacheKey,
|
||||
strokes,
|
||||
draft: null,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
dispatch(event: SceneMarkerEvent): SceneMarkerState {
|
||||
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();
|
||||
}
|
||||
case 'strokes.erase': {
|
||||
const strokes = applySceneMarkerEraserStroke(
|
||||
this.state.strokes,
|
||||
event.points,
|
||||
event.radiusN,
|
||||
(prefix) => makeStrokeId(prefix),
|
||||
);
|
||||
if (this.currentKey) {
|
||||
this.cache.set(this.currentKey, strokes);
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
strokes,
|
||||
draft: null,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
case 'strokes.clear':
|
||||
if (this.currentKey) {
|
||||
this.cache.set(this.currentKey, []);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user