From 8b28a9b11a131a57a681759deff711c66e773234 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Wed, 2 Sep 2026 12:53:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(control):=20=D0=B8=D0=BD=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=20=C2=AB=D0=9C=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=C2=BB=20=D0=BD=D0=B0=20=D0=BE=D0=B1=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена кисть маркера на пульте и презентации с настройками в панели эффектов; штрихи привязаны к жёлтой рамке, ластик и очистка работают с отдельным сессионным слоем. Co-authored-by: Cursor --- app/main/effects/sceneMarkerStore.test.ts | 95 +++++++ app/main/effects/sceneMarkerStore.ts | 108 +++++++ app/main/index.ts | 34 +++ app/renderer/control/ControlApp.module.css | 27 ++ app/renderer/control/ControlApp.tsx | 266 ++++++++++++++++-- .../control/controlApp.effectsPanel.test.ts | 9 + app/renderer/editor/i18n/editorMessages.ts | 4 + app/renderer/shared/PresentationView.tsx | 29 +- .../shared/effects/SceneMarkerOverlay.tsx | 131 +++++++++ .../shared/effects/useSceneMarkerState.ts | 37 +++ .../shared/videoSceneMapParity.test.ts | 3 + app/shared/ipc/contracts.ts | 17 ++ app/shared/sceneMarkerEraser.test.ts | 30 ++ app/shared/sceneMarkerEraser.ts | 107 +++++++ app/shared/types/effects.ts | 1 + app/shared/types/index.ts | 1 + app/shared/types/sceneMarker.ts | 37 +++ package.json | 2 +- 18 files changed, 906 insertions(+), 32 deletions(-) create mode 100644 app/main/effects/sceneMarkerStore.test.ts create mode 100644 app/main/effects/sceneMarkerStore.ts create mode 100644 app/renderer/shared/effects/SceneMarkerOverlay.tsx create mode 100644 app/renderer/shared/effects/useSceneMarkerState.ts create mode 100644 app/shared/sceneMarkerEraser.test.ts create mode 100644 app/shared/sceneMarkerEraser.ts create mode 100644 app/shared/types/sceneMarker.ts diff --git a/app/main/effects/sceneMarkerStore.test.ts b/app/main/effects/sceneMarkerStore.test.ts new file mode 100644 index 0000000..de4fc07 --- /dev/null +++ b/app/main/effects/sceneMarkerStore.test.ts @@ -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); +}); diff --git a/app/main/effects/sceneMarkerStore.ts b/app/main/effects/sceneMarkerStore.ts new file mode 100644 index 0000000..36ca9cb --- /dev/null +++ b/app/main/effects/sceneMarkerStore.ts @@ -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(); + 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(); + } + } + } +} diff --git a/app/main/index.ts b/app/main/index.ts index b35ae20..33dd3ed 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -27,6 +27,7 @@ import { import { EffectsStore, effectsDefaultTool } from './effects/effectsStore'; import { SceneDarknessStore } from './effects/sceneDarknessStore'; +import { SceneMarkerStore } from './effects/sceneMarkerStore'; import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore'; import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router'; import { LicenseService } from './license/licenseService'; @@ -165,6 +166,7 @@ function installAppMenuForSession(): void { const effectsStore = new EffectsStore(); const sceneDarknessStore = new SceneDarknessStore(); +const sceneMarkerStore = new SceneMarkerStore(); const sceneTrapsStore = new SceneTrapsStore(); const sceneViewStore = new SceneViewStore(); const videoStore = new VideoPlaybackStore(); @@ -270,6 +272,13 @@ function emitSceneDarknessState(): void { } } +function emitSceneMarkerState(): void { + const state = sceneMarkerStore.getState(); + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.sceneMarker.stateChanged, { state }); + } +} + function emitSceneViewState(): void { const state = sceneViewStore.getState(); for (const win of BrowserWindow.getAllWindows()) { @@ -357,6 +366,11 @@ function syncSceneDarknessForProject(project: Project): void { sceneDarknessStore.switchScene(cacheKey, enabled); } +function syncSceneMarkerForProject(project: Project): void { + const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; + sceneMarkerStore.switchScene(cacheKey); +} + function emitSceneTrapsState(): void { const state = sceneTrapsStore.getState(); for (const win of BrowserWindow.getAllWindows()) { @@ -522,6 +536,7 @@ async function main() { registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); registerHandler(ipcChannels.windows.openMultiWindow, (req) => { sceneDarknessStore.resetSession(); + sceneMarkerStore.resetSession(); sceneTrapsStore.resetSession(); sceneTokensSessionStore.reset(); tokenPathSessionStore.reset(); @@ -540,12 +555,14 @@ async function main() { const project = projectStore.getOpenProject(); if (project) { syncSceneDarknessForProject(project); + syncSceneMarkerForProject(project); syncSceneTrapsForProject(project); seedTokenPathPlaybackForProject(project); } else { emitTokenPathSessionState(); } emitSceneDarknessState(); + emitSceneMarkerState(); emitSceneTrapsState(); emitSceneTokensSessionState(); emitSceneNpcTokensSessionState(); @@ -674,10 +691,12 @@ async function main() { sceneNpcTokensSessionStore.reset(); clearNpcMapSpawnDrag(); scenePlayerTokensSessionStore.reset(); + syncSceneMarkerForProject(project); emitSceneViewState(); emitSceneTokensSessionState(); emitSceneNpcTokensSessionState(); emitScenePlayerTokensSessionState(); + emitSceneMarkerState(); emitSessionState(); warmNpcsEditorWindow(); return { project }; @@ -689,6 +708,7 @@ async function main() { materialsOverlayStore.clear(); npcsOverlayStore.clear(); sceneDarknessStore.resetSession(); + sceneMarkerStore.resetSession(); sceneTrapsStore.resetSession(); sceneViewStore.reset(); sceneTokensSessionStore.reset(); @@ -700,6 +720,7 @@ async function main() { emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); + emitSceneMarkerState(); emitSceneTrapsState(); emitSceneViewState(); emitSceneTokensSessionState(); @@ -727,12 +748,14 @@ async function main() { const project = projectStore.getOpenProject(); if (project) { syncSceneDarknessForProject(project); + syncSceneMarkerForProject(project); syncSceneTrapsForProject(project); } emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); + emitSceneMarkerState(); emitSceneTrapsState(); emitSceneViewState(); emitSceneTokensSessionState(); @@ -755,12 +778,14 @@ async function main() { const project = projectStore.getOpenProject(); if (project) { syncSceneDarknessForProject(project); + syncSceneMarkerForProject(project); syncSceneTrapsForProject(project); } emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); + emitSceneMarkerState(); emitSceneTrapsState(); emitSceneViewState(); emitSceneTokensSessionState(); @@ -1427,6 +1452,15 @@ async function main() { return { ok: true }; }); + registerHandler(ipcChannels.sceneMarker.getState, () => { + return { state: sceneMarkerStore.getState() }; + }); + registerHandler(ipcChannels.sceneMarker.dispatch, ({ event }) => { + sceneMarkerStore.dispatch(event); + emitSceneMarkerState(); + return { ok: true }; + }); + registerHandler(ipcChannels.sceneTraps.getState, () => { return { state: sceneTrapsStore.getState() }; }); diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index f99d483..9e804b7 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -453,6 +453,33 @@ color: var(--text1); } +.markerColorInput { + flex: 0 0 40px; + width: 40px; + height: 34px; + padding: 0; + border: 1px solid var(--stroke); + border-radius: var(--radius-sm); + background: transparent; + cursor: pointer; + overflow: hidden; +} + +.markerColorInput::-webkit-color-swatch-wrapper { + padding: 0; + border-radius: inherit; +} + +.markerColorInput::-webkit-color-swatch { + border: none; + border-radius: inherit; +} + +.markerColorInput::-moz-color-swatch { + border: none; + border-radius: inherit; +} + .brushLayer { position: absolute; inset: 0; diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 0543d54..8ba4834 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -17,9 +17,11 @@ import type { NpcDisposition, Scene, SceneDarknessBrushShape, + SceneMarkerBrushShape, SceneId, SceneViewCamera, } from '../../shared/types'; +import { normalizeHexColor } from '../../shared/npcs/npcGroups'; import { DEFAULT_NPC_TOKEN_SESSION_SCALE, DEFAULT_SCENE_NPC_TOKEN_SIZE_N, @@ -47,8 +49,10 @@ import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay'; import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay'; import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects'; import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; +import { SceneMarkerOverlay } from '../shared/effects/SceneMarkerOverlay'; import { useEffectsState } from '../shared/effects/useEffectsState'; import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState'; +import { useSceneMarkerState } from '../shared/effects/useSceneMarkerState'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT, defaultLegendLayoutForMaterial } from '../../shared/types'; import { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel'; import { MaterialOverlay } from '../shared/materials/MaterialOverlay'; @@ -160,6 +164,7 @@ export function ControlApp() { const [fxState, fx] = useEffectsState(); const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain()); const [sdState, sd] = useSceneDarknessState(); + const [markerState, marker] = useSceneMarkerState(); const [sceneTraps, sceneTrapsApi] = useSceneTrapsState(); const appTokens = useAppTokens(); const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession(); @@ -190,6 +195,12 @@ export function ControlApp() { const [darknessBrushShape, setDarknessBrushShape] = useState('circle'); const darknessBrushShapeRef = useRef(darknessBrushShape); darknessBrushShapeRef.current = darknessBrushShape; + const [markerBrushShape, setMarkerBrushShape] = useState('circle'); + const markerBrushShapeRef = useRef(markerBrushShape); + markerBrushShapeRef.current = markerBrushShape; + const [markerColor, setMarkerColor] = useState('#ff0000'); + const markerColorRef = useRef(markerColor); + markerColorRef.current = markerColor; /** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */ const mainStoryReturnRef = useRef(null); const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState(null); @@ -232,6 +243,7 @@ export function ControlApp() { | 'freeze' | 'exploreBrush' | 'closeBrush' + | 'marker' | 'eraser'; startN?: { x: number; y: number }; points?: { x: number; y: number; tMs: number }[]; @@ -262,6 +274,16 @@ export function ControlApp() { presentationContentSize.height, ); }, [presentationContentSize, previewSize.h, previewSize.w]); + /** Область видимости (жёлтая рамка на пульте); маркер рисуется в этих координатах. */ + const markerViewport = useMemo(() => { + if (presentationScreenRect) return presentationScreenRect; + if (previewSize.w > 1 && previewSize.h > 1) { + return { x: 0, y: 0, w: previewSize.w, h: previewSize.h }; + } + return previewContentRect; + }, [presentationScreenRect, previewContentRect, previewSize.h, previewSize.w]); + const markerViewportRef = useRef(markerViewport); + markerViewportRef.current = markerViewport; const brushCursorElRef = useRef(null); const cursorPosRef = useRef<{ x: number; y: number } | null>(null); const draftPaintRafRef = useRef(0); @@ -1123,9 +1145,21 @@ export function ControlApp() { if (leavingDarknessBrush) { setDarknessBrushShape('circle'); } + if (tool.tool === 'marker' && toolName !== 'marker') { + setMarkerBrushShape('circle'); + } void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: toolName } }); } + function markerBrushDraft(points: { x: number; y: number; tMs: number }[]) { + return { + points, + radiusN: toolRef.current.radiusN, + color: markerColorRef.current, + shape: markerBrushShapeRef.current, + }; + } + function darknessBrushDraft(points: { x: number; y: number; tMs: number }[], mode: 'reveal' | 'cover') { return { points, @@ -1138,7 +1172,8 @@ export function ControlApp() { function layoutBrushCursor(): void { const el = brushCursorElRef.current; const p = cursorPosRef.current; - const cr = previewContentRectRef.current; + const cr = + toolRef.current.tool === 'marker' ? markerViewportRef.current : previewContentRectRef.current; const ps = previewSizeRef.current; const t = toolRef.current; if (!el) return; @@ -1148,9 +1183,11 @@ export function ControlApp() { } el.style.visibility = 'visible'; const isDarknessBrush = t.tool === 'exploreBrush' || t.tool === 'closeBrush'; + const isMarkerBrush = t.tool === 'marker'; el.className = [ styles.brushCursor, isDarknessBrush && darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '', + isMarkerBrush && markerBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '', ] .filter(Boolean) .join(' '); @@ -1337,12 +1374,26 @@ export function ControlApp() { ), }); } + if (b?.tool === 'marker' && b.points) { + void marker.dispatch({ + kind: 'draft.set', + draft: markerBrushDraft(b.points), + }); + } }); } useLayoutEffect(() => { layoutBrushCursor(); - }, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h, darknessBrushShape]); + }, [ + tool.radiusN, + previewContentRect, + markerViewport, + previewSize.w, + previewSize.h, + darknessBrushShape, + markerBrushShape, + ]); useEffect(() => { return () => { @@ -1352,20 +1403,36 @@ export function ControlApp() { }; }, []); - function toNPoint(e: React.PointerEvent): { x: number; y: number } | null { + function normPointInViewport( + clientX: number, + clientY: number, + viewport: { x: number; y: number; w: number; h: number } | null, + opts?: { rejectOutside?: boolean }, + ): { x: number; y: number } | null { const host = previewHostRef.current; - if (!host) return null; + if (!host || !viewport) return null; const r = host.getBoundingClientRect(); - const cr = previewContentRectRef.current; - const ox = cr ? cr.x : 0; - const oy = cr ? cr.y : 0; - const cw = cr ? cr.w : r.width; - const ch = cr ? cr.h : r.height; - const x = (e.clientX - (r.left + ox)) / Math.max(1, cw); - const y = (e.clientY - (r.top + oy)) / Math.max(1, ch); + const x = (clientX - (r.left + viewport.x)) / Math.max(1, viewport.w); + const y = (clientY - (r.top + viewport.y)) / Math.max(1, viewport.h); + if (opts?.rejectOutside && (x < 0 || x > 1 || y < 0 || y > 1)) return null; return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }; } + function toNPoint(e: React.PointerEvent): { x: number; y: number } | null { + return normPointInViewport(e.clientX, e.clientY, previewContentRectRef.current); + } + + function toMarkerNPoint(e: React.PointerEvent): { x: number; y: number } | null { + return normPointInViewport(e.clientX, e.clientY, markerViewportRef.current, { + rejectOutside: true, + }); + } + + function brushPointFromEvent(e: React.PointerEvent): { x: number; y: number } | null { + if (toolRef.current.tool === 'marker') return toMarkerNPoint(e); + return toNPoint(e); + } + function dispatchFieldEraserPoints( points: { x: number; y: number; tMs: number }[], opts?: { @@ -1383,6 +1450,15 @@ export function ControlApp() { }); } + function dispatchMarkerEraserPoints(points: { x: number; y: number; tMs: number }[]): void { + if (points.length === 0) return; + void marker.dispatch({ + kind: 'strokes.erase', + points, + radiusN: toolRef.current.radiusN, + }); + } + /** Вода/дождь гасят огонь только под самой кистью (не «аура» радиуса огня). */ function extinguishFireAlong(points: { x: number; y: number; tMs: number }[]): void { dispatchFieldEraserPoints(points, { types: ['fire'], hitMode: 'brush' }); @@ -1496,6 +1572,20 @@ export function ControlApp() { }, }); } + if (b.tool === 'marker' && b.points && b.points.length > 0) { + await marker.dispatch({ + kind: 'stroke.add', + stroke: { + id: `mk_${String(createdAtMs)}_${String(seed)}`, + seed, + createdAtMs, + points: b.points, + radiusN: tool.radiusN, + color: markerColorRef.current, + shape: markerBrushShapeRef.current, + }, + }); + } if (b.tool === 'darkness' && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return; @@ -1668,6 +1758,17 @@ export function ControlApp() {
{t('control.instruments')}
+
) : null} + {tool.tool === 'marker' ? ( + <> +
+
{t('control.brushShape')}
+
+ + +
+
+
+
{t('control.markerColor')}
+ { + const next = normalizeHexColor((e.currentTarget as HTMLInputElement).value, '#ff0000'); + setMarkerColor(next); + }} + className={styles.markerColorInput} + aria-label={t('control.markerColor')} + /> +
+ + ) : null}
{t('control.effectsSound')}
{ - const p = toNPoint(e); + const p = brushPointFromEvent(e); if (!p) return; cursorPosRef.current = p; layoutBrushCursor(); @@ -2243,16 +2411,23 @@ export function ControlApp() { } if (e.button !== 0) return; if (tool.tool === 'none') return; - const p = toNPoint(e); + const p = brushPointFromEvent(e); if (!p) return; cursorPosRef.current = p; layoutBrushCursor(); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); if (tool.tool === 'eraser') { - const pt = { x: p.x, y: p.y, tMs: Date.now() }; - brushRef.current = { tool: 'eraser', startN: p, points: [pt] }; - dispatchFieldEraserPoints([pt]); - tryEraseActionEffect(p); + const pFx = toNPoint(e); + const pMk = toMarkerNPoint(e); + if (!pFx) return; + const tMs = Date.now(); + const ptFx = { x: pFx.x, y: pFx.y, tMs }; + brushRef.current = { tool: 'eraser', startN: pFx, points: [ptFx] }; + dispatchFieldEraserPoints([ptFx]); + if (pMk) { + dispatchMarkerEraserPoints([{ x: pMk.x, y: pMk.y, tMs }]); + } + tryEraseActionEffect(pFx); return; } draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 }; @@ -2274,6 +2449,12 @@ export function ControlApp() { ), }); } + if (tool.tool === 'marker') { + void marker.dispatch({ + kind: 'draft.set', + draft: markerBrushDraft([startPt]), + }); + } pushDraftToPixi(); }} onPointerMove={(e) => { @@ -2291,31 +2472,46 @@ export function ControlApp() { publishSceneViewCamera(sceneViewPanBy(cam, { containW, containH, dx, dy })); return; } - const p = toNPoint(e); - if (!p) return; - cursorPosRef.current = p; - layoutBrushCursor(); + const p = brushPointFromEvent(e); + if (tool.tool !== 'eraser' && !p) return; + if (p) { + cursorPosRef.current = p; + layoutBrushCursor(); + } if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) { + const pFx = toNPoint(e); + const pMk = toMarkerNPoint(e); + if (!pFx) return; const b = brushRef.current; - const pt = { x: p.x, y: p.y, tMs: Date.now() }; + const tMs = Date.now(); + const ptFx = { x: pFx.x, y: pFx.y, tMs }; if (b?.tool !== 'eraser' || !b.points) { - brushRef.current = { tool: 'eraser', startN: p, points: [pt] }; - dispatchFieldEraserPoints([pt]); + brushRef.current = { tool: 'eraser', startN: pFx, points: [ptFx] }; + dispatchFieldEraserPoints([ptFx]); + if (pMk) { + dispatchMarkerEraserPoints([{ x: pMk.x, y: pMk.y, tMs }]); + } } else { const last = b.points[b.points.length - 1]; if (last) { - const dx = p.x - last.x; - const dy = p.y - last.y; + const dx = pFx.x - last.x; + const dy = pFx.y - last.y; const minStep = Math.max(0.004, tool.radiusN * 0.25); if (dx * dx + dy * dy >= minStep * minStep) { - b.points.push(pt); - dispatchFieldEraserPoints([last, pt]); + b.points.push(ptFx); + dispatchFieldEraserPoints([last, ptFx]); + if (pMk) { + dispatchMarkerEraserPoints([ + { x: pMk.x, y: pMk.y, tMs }, + ]); + } } } } - tryEraseActionEffect(p); + tryEraseActionEffect(pFx); return; } + if (!p) return; const b = brushRef.current; if (!b?.points) return; const last = b.points[b.points.length - 1]; @@ -2353,6 +2549,9 @@ export function ControlApp() { ) { void sd.dispatch({ kind: 'draft.set', draft: null }); } + if (brushRef.current?.tool === 'marker') { + void marker.dispatch({ kind: 'draft.set', draft: null }); + } brushRef.current = null; clearDraftFromPixi(); }} @@ -2686,6 +2885,13 @@ export function ControlApp() { ); })()} + {markerViewport ? ( + + ) : null}
diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index f3676d9..1f2bf45 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -78,6 +78,15 @@ void test('ControlApp: эффекты в пульте, иконки с тулт const src = readControlApp(); assert.ok(src.includes("t('control.instruments')")); assert.ok(src.includes("t('control.descriptionTool')")); + assert.ok(src.includes("t('control.markerTool')")); + assert.ok(src.includes("selectEffectTool('marker')")); + assert.ok(src.includes('SceneMarkerOverlay')); + assert.ok(src.includes('useSceneMarkerState')); + assert.ok(src.includes("t('control.markerColor')")); + assert.ok(src.includes('dispatchMarkerEraserPoints')); + assert.ok(src.includes('markerViewport')); + assert.ok(src.includes('toMarkerNPoint')); + assert.ok(src.includes("kind: 'strokes.clear'")); assert.ok(src.includes("t('control.descriptionMissing')")); assert.ok(src.includes('openSceneDescription')); assert.ok(!src.includes('SceneDescriptionViewModal')); diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index a6c8a58..f237c97 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -607,6 +607,8 @@ export const EDITOR_MESSAGES: Record> = { 'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ', 'control.instruments': 'ИНСТРУМЕНТЫ', 'control.descriptionTool': 'Описание', + 'control.markerTool': 'Маркер', + 'control.markerColor': 'Цвет маркера', 'control.materialsTool': 'Материалы', 'control.npcsTool': 'НПС', 'control.descriptionMissing': 'Описание отсутствует', @@ -1247,6 +1249,8 @@ export const EDITOR_MESSAGES: Record> = { 'control.remoteTitle': 'CONTROL PANEL', 'control.instruments': 'TOOLS', 'control.descriptionTool': 'Description', + 'control.markerTool': 'Marker', + 'control.markerColor': 'Marker color', 'control.materialsTool': 'Materials', 'control.npcsTool': 'NPCs', 'control.descriptionMissing': 'No description', diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index 2dabe02..32fef70 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -7,8 +7,10 @@ import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranch import { ExplosionVideoOverlay } from './effects/ExplosionVideoOverlay'; import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay'; import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay'; +import { SceneMarkerOverlay } from './effects/SceneMarkerOverlay'; import { useEffectsState } from './effects/useEffectsState'; import { useSceneDarknessState } from './effects/useSceneDarknessState'; +import { useSceneMarkerState } from './effects/useSceneMarkerState'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT, defaultLegendLayoutForMaterial } from '../../shared/types'; import { SceneGridOverlay } from './grid/SceneGridOverlay'; import { MaterialLegendPanel } from './materials/MaterialLegendPanel'; @@ -57,6 +59,7 @@ export function PresentationView({ }: PresentationViewProps) { const [fxState] = useEffectsState(); const [sdState] = useSceneDarknessState(); + const [markerState] = useSceneMarkerState(); const [sceneTraps] = useSceneTrapsState(); const [sceneView] = useSceneViewState(); const [materialsOverlay] = useMaterialsOverlayState(); @@ -70,9 +73,30 @@ export function PresentationView({ const [tokenPathSession, tokenPathApi] = useTokenPathSession(); const [vp] = useVideoPlaybackState(); const videoElRef = useRef(null); + const rootRef = useRef(null); const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>( null, ); + const [markerViewport, setMarkerViewport] = React.useState<{ + x: number; + y: number; + w: number; + h: number; + } | null>(null); + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const sync = () => { + const w = Math.max(1, el.clientWidth); + const h = Math.max(1, el.clientHeight); + setMarkerViewport({ x: 0, y: 0, w, h }); + }; + sync(); + const ro = new ResizeObserver(sync); + ro.observe(el); + return () => ro.disconnect(); + }, []); const scene = session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined; const project = session?.project; @@ -181,7 +205,7 @@ export function PresentationView({ ]); return ( -
+
{shownImageUrl && scene?.previewAssetType === 'image' ? (
) : null} + {markerViewport ? ( + + ) : null} 0 || activeNpcItems.length > 0} legendLayer={ diff --git a/app/renderer/shared/effects/SceneMarkerOverlay.tsx b/app/renderer/shared/effects/SceneMarkerOverlay.tsx new file mode 100644 index 0000000..91a8262 --- /dev/null +++ b/app/renderer/shared/effects/SceneMarkerOverlay.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useRef } from 'react'; + +import type { + SceneMarkerBrushShape, + SceneMarkerDraft, + SceneMarkerState, + SceneMarkerStroke, +} from '../../../shared/types'; +import { normalizeSceneMarkerBrushShape } from '../../../shared/types/sceneMarker'; + +export type SceneMarkerOverlayProps = { + state: SceneMarkerState | null; + viewport?: { x: number; y: number; w: number; h: number }; + style?: React.CSSProperties; +}; + +type MarkerStrokeLike = SceneMarkerStroke | SceneMarkerDraft; + +function stampSquare( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + side: number, +): void { + ctx.fillRect(x - side / 2, y - side / 2, side, side); +} + +function drawSquareMarkerStroke( + ctx: CanvasRenderingContext2D, + stroke: MarkerStrokeLike, + w: number, + h: number, +): void { + const pts = stroke.points; + if (pts.length === 0) return; + const side = stroke.radiusN * Math.min(w, h) * 2; + for (const p of pts) { + stampSquare(ctx, p.x * w, p.y * h, side); + } +} + +function drawCircleMarkerStroke( + ctx: CanvasRenderingContext2D, + stroke: MarkerStrokeLike, + w: number, + h: number, +): void { + const pts = stroke.points; + if (pts.length === 0) return; + const r = stroke.radiusN * Math.min(w, h); + if (pts.length === 1) { + const p = pts[0]; + if (!p) return; + ctx.beginPath(); + ctx.arc(p.x * w, p.y * h, r, 0, Math.PI * 2); + ctx.fill(); + return; + } + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.lineWidth = r * 2; + ctx.beginPath(); + const first = pts[0]; + if (!first) return; + ctx.moveTo(first.x * w, first.y * h); + for (let i = 1; i < pts.length; i += 1) { + const p = pts[i]; + if (!p) continue; + ctx.lineTo(p.x * w, p.y * h); + } + ctx.stroke(); +} + +function drawMarkerStroke( + ctx: CanvasRenderingContext2D, + stroke: MarkerStrokeLike, + w: number, + h: number, +): void { + const shape: SceneMarkerBrushShape = normalizeSceneMarkerBrushShape(stroke.shape); + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = stroke.color; + ctx.strokeStyle = stroke.color; + if (shape === 'square') { + drawSquareMarkerStroke(ctx, stroke, w, h); + return; + } + drawCircleMarkerStroke(ctx, stroke, w, h); +} + +export function SceneMarkerOverlay({ state, viewport, style }: SceneMarkerOverlayProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !state || !viewport) return; + const w = Math.max(1, Math.round(viewport.w)); + const h = Math.max(1, Math.round(viewport.h)); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.clearRect(0, 0, w, h); + for (const stroke of state.strokes) { + drawMarkerStroke(ctx, stroke, w, h); + } + if (state.draft && state.draft.points.length > 0) { + drawMarkerStroke(ctx, state.draft, w, h); + } + }, [state, viewport]); + + if (!state || !viewport) return null; + + return ( + + ); +} diff --git a/app/renderer/shared/effects/useSceneMarkerState.ts b/app/renderer/shared/effects/useSceneMarkerState.ts new file mode 100644 index 0000000..4540f8d --- /dev/null +++ b/app/renderer/shared/effects/useSceneMarkerState.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; + +import { ipcChannels } from '../../../shared/ipc/contracts'; +import type { SceneMarkerEvent, SceneMarkerState } from '../../../shared/types'; +import { getDndApi } from '../dndApi'; + +export function useSceneMarkerState(): readonly [ + SceneMarkerState | null, + { dispatch: (event: SceneMarkerEvent) => Promise }, +] { + const api = getDndApi(); + const [state, setState] = useState(null); + + useEffect(() => { + void api.invoke(ipcChannels.sceneMarker.getState, {}).then((r) => { + setState((prev) => { + if (prev && prev.revision > r.state.revision) return prev; + return r.state; + }); + }); + return api.on(ipcChannels.sceneMarker.stateChanged, ({ state: next }) => { + setState((prev) => { + if (prev && prev.revision > next.revision) return prev; + return next; + }); + }); + }, [api]); + + return [ + state, + { + dispatch: async (event) => { + await api.invoke(ipcChannels.sceneMarker.dispatch, { event }); + }, + }, + ] as const; +} diff --git a/app/renderer/shared/videoSceneMapParity.test.ts b/app/renderer/shared/videoSceneMapParity.test.ts index 2ddfdba..ddd940e 100644 --- a/app/renderer/shared/videoSceneMapParity.test.ts +++ b/app/renderer/shared/videoSceneMapParity.test.ts @@ -22,6 +22,9 @@ void test('video scenes share map overlays / effects with image scenes', () => { assert.ok(presentation.includes('SceneTrapsOverlay')); assert.ok(presentation.includes('PixiEffectsOverlay')); assert.ok(presentation.includes('SceneDarknessOverlay')); + assert.ok(presentation.includes('SceneMarkerOverlay')); + assert.ok(presentation.includes('markerViewport')); + assert.ok(main.includes('syncSceneMarkerForProject')); assert.ok(sceneEditor.includes('ContainedVideo')); assert.ok(sceneEditor.includes('hasMapMedia')); diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 097b499..e227111 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -27,6 +27,8 @@ import type { Scene, SceneDarknessEvent, SceneDarknessState, + SceneMarkerEvent, + SceneMarkerState, SceneGrid, SceneId, SceneNpcToken, @@ -182,6 +184,11 @@ export const ipcChannels = { dispatch: 'sceneDarkness.dispatch', stateChanged: 'sceneDarkness.stateChanged', }, + sceneMarker: { + getState: 'sceneMarker.getState', + dispatch: 'sceneMarker.dispatch', + stateChanged: 'sceneMarker.stateChanged', + }, sceneTraps: { getState: 'sceneTraps.getState', dispatch: 'sceneTraps.dispatch', @@ -315,6 +322,7 @@ export type IpcEventMap = { [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; + [ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.tokens.stateChanged]: { tokens: AppToken[] }; @@ -771,6 +779,14 @@ export type IpcInvokeMap = { req: { event: SceneDarknessEvent }; res: { ok: true }; }; + [ipcChannels.sceneMarker.getState]: { + req: Record; + res: { state: SceneMarkerState }; + }; + [ipcChannels.sceneMarker.dispatch]: { + req: { event: SceneMarkerEvent }; + res: { ok: true }; + }; [ipcChannels.sceneTraps.getState]: { req: Record; res: { state: SceneTrapsState }; @@ -952,6 +968,7 @@ export type LegacyIpcEventMap = { [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; + [ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.tokens.stateChanged]: { tokens: AppToken[] }; diff --git a/app/shared/sceneMarkerEraser.test.ts b/app/shared/sceneMarkerEraser.test.ts new file mode 100644 index 0000000..ffc4964 --- /dev/null +++ b/app/shared/sceneMarkerEraser.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { applySceneMarkerEraserStroke } from './sceneMarkerEraser'; +import type { SceneMarkerStroke } from './types/sceneMarker'; + +void test('applySceneMarkerEraserStroke: удаляет точки под ластиком', () => { + const strokes: SceneMarkerStroke[] = [ + { + 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', + }, + ]; + const next = applySceneMarkerEraserStroke( + strokes, + [{ x: 0.5, y: 0.5, tMs: 200 }], + 0.06, + (prefix) => `${prefix}_new`, + ); + assert.ok(next.length >= 1); + assert.ok(next.every((s) => s.points.every((p) => Math.abs(p.x - 0.5) > 0.05))); +}); diff --git a/app/shared/sceneMarkerEraser.ts b/app/shared/sceneMarkerEraser.ts new file mode 100644 index 0000000..d52164b --- /dev/null +++ b/app/shared/sceneMarkerEraser.ts @@ -0,0 +1,107 @@ +import type { NPoint } from './types/effects'; +import type { SceneMarkerStroke } from './types/sceneMarker'; + +function distSqPointToSegment( + px: number, + py: number, + x1: number, + y1: number, + x2: number, + y2: number, +): number { + const dx = x2 - x1; + const dy = y2 - y1; + const len2 = dx * dx + dy * dy; + if (len2 < 1e-18) { + const ex = px - x1; + const ey = py - y1; + return ex * ex + ey * ey; + } + let t = ((px - x1) * dx + (py - y1) * dy) / len2; + t = Math.max(0, Math.min(1, t)); + const qx = x1 + t * dx; + const qy = y1 + t * dy; + const ex = px - qx; + const ey = py - qy; + return ex * ex + ey * ey; +} + +function minDistToEraserStroke( + p: { x: number; y: number }, + eraserPoints: readonly { x: number; y: number }[], +): number { + if (eraserPoints.length === 0) return Number.POSITIVE_INFINITY; + let bestSq = Number.POSITIVE_INFINITY; + for (const ep of eraserPoints) { + const dx = p.x - ep.x; + const dy = p.y - ep.y; + bestSq = Math.min(bestSq, dx * dx + dy * dy); + } + for (let i = 1; i < eraserPoints.length; i += 1) { + const a = eraserPoints[i - 1]; + const b = eraserPoints[i]; + if (!a || !b) continue; + bestSq = Math.min(bestSq, distSqPointToSegment(p.x, p.y, a.x, a.y, b.x, b.y)); + } + return Math.sqrt(bestSq); +} + +function isPointErased( + p: { x: number; y: number }, + strokeRadiusN: number, + eraserPoints: readonly { x: number; y: number }[], + eraserRadiusN: number, +): boolean { + const threshold = eraserRadiusN + strokeRadiusN; + return minDistToEraserStroke(p, eraserPoints) <= threshold; +} + +function splitKeptPointRuns( + points: NPoint[], + eraserPoints: readonly { x: number; y: number }[], + eraserRadiusN: number, + strokeRadiusN: number, +): NPoint[][] { + const runs: NPoint[][] = []; + let current: NPoint[] = []; + for (const p of points) { + if (isPointErased(p, strokeRadiusN, eraserPoints, eraserRadiusN)) { + if (current.length > 0) { + runs.push(current); + current = []; + } + continue; + } + current.push(p); + } + if (current.length > 0) runs.push(current); + return runs; +} + +/** Стирает штрихи маркера ластиком (как туман/огонь). */ +export function applySceneMarkerEraserStroke( + strokes: readonly SceneMarkerStroke[], + eraserPoints: readonly NPoint[], + eraserRadiusN: number, + makeId: (prefix: string) => string, +): SceneMarkerStroke[] { + if (eraserPoints.length === 0) return [...strokes]; + const out: SceneMarkerStroke[] = []; + for (const stroke of strokes) { + const runs = splitKeptPointRuns(stroke.points, eraserPoints, eraserRadiusN, stroke.radiusN); + if (runs.length === 0) continue; + if (runs.length === 1 && runs[0]?.length === stroke.points.length) { + out.push(stroke); + continue; + } + for (const run of runs) { + if (run.length === 0) continue; + out.push({ + ...stroke, + id: makeId('mk'), + points: run, + }); + } + } + return out; +} diff --git a/app/shared/types/effects.ts b/app/shared/types/effects.ts index e50221f..3b0c627 100644 --- a/app/shared/types/effects.ts +++ b/app/shared/types/effects.ts @@ -12,6 +12,7 @@ export type EffectToolType = | 'freeze' | 'exploreBrush' | 'closeBrush' + | 'marker' | 'eraser'; export type EffectInstanceType = diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index 3152031..fec4194 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -9,6 +9,7 @@ export * from './npcDisposition'; export * from './npcExternalUrl'; export * from './npcs'; export * from './sceneDarkness'; +export * from './sceneMarker'; export * from './sceneGrid'; export * from './sceneGridSnap'; export * from './tokenPath'; diff --git a/app/shared/types/sceneMarker.ts b/app/shared/types/sceneMarker.ts new file mode 100644 index 0000000..797864c --- /dev/null +++ b/app/shared/types/sceneMarker.ts @@ -0,0 +1,37 @@ +import type { NPoint } from './effects'; + +export type SceneMarkerBrushShape = 'circle' | 'square'; + +export type SceneMarkerStroke = { + id: string; + seed: number; + createdAtMs: number; + points: NPoint[]; + radiusN: number; + color: string; + shape?: SceneMarkerBrushShape; +}; + +export type SceneMarkerDraft = { + points: NPoint[]; + radiusN: number; + color: string; + shape?: SceneMarkerBrushShape; +}; + +export type SceneMarkerState = { + revision: number; + cacheKey: string | null; + strokes: SceneMarkerStroke[]; + draft: SceneMarkerDraft | null; +}; + +export type SceneMarkerEvent = + | { kind: 'draft.set'; draft: SceneMarkerDraft | null } + | { kind: 'stroke.add'; stroke: SceneMarkerStroke } + | { kind: 'strokes.erase'; points: NPoint[]; radiusN: number } + | { kind: 'strokes.clear' }; + +export function normalizeSceneMarkerBrushShape(raw: unknown): SceneMarkerBrushShape { + return raw === 'square' ? 'square' : 'circle'; +} diff --git a/package.json b/package.json index 4df78f3..6bc6917 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package",