94 Commits

Author SHA1 Message Date
Ivan Fontosh 4456eb0277 feat(control): grid snap, NPC context actions, and overlay dim fix
Re-enable users-branch UI, snap session tokens to square/hex grid from the control preview, refine inactive/open-info NPC menus, block marker actions while an effect brush is active, and dim materials/NPC overlays only when they are open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:48:59 +08:00
     Фонтош Иван Сергеевич e53d1ea934 chore: bump version to 1.0.28
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 10:19:41 +08:00
Ivan Fontosh cbb6edc378 chore(release): gate users-branch features for hotfix
Add USERS_BRANCH_FEATURES_ENABLED (off by default) to hide Players library, session tokens, and related UI while keeping the preview import fix. Restore primary Run button when the flag is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 10:15:28 +08:00
Ivan Fontosh 4e6f7321b8 fix(project): race-safe batch scene preview import and close
Serialize updateProject and preview finalize, drain before pack/close, buffer zip entries, and abort finalize cleanly when leaving the project to avoid ENOENT on multi-image drops.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 09:44:45 +08:00
Ivan Fontosh f75444a5dd Merge branch 'users' into main
Players library, circular NPC tokens, disposition types; keep main overlay/release fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 08:03:22 +08:00
Ivan Fontosh 10cbb3b256 fix(release): unlock rolldown before npm ci in prepare-release
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:17:37 +08:00
Ivan Fontosh c46ff34393 chore: revert accidental bump to 1.0.28
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:17:25 +08:00
Ivan Fontosh 7a25b18268 chore: release v1.0.28 2026-08-03 14:17:11 +08:00
     Фонтош Иван Сергеевич d4dc4e7f3c chore: bump version to 1.0.27
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:06:29 +08:00
Ivan Fontosh 2979d06f1c feat: multi-material overlays, presentation guides, and release prebuilds
Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:28:18 +08:00
Ivan Fontosh 61446dacfc fix(ui): hide Electron menu bar and polish editor chrome
Drop the system View menu across windows, show the open project name above Game properties, and keep scene list kebab visible with truncated titles and overflow tooltips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:21:26 +08:00
Ivan Fontosh 41b112159f feat(effects): add Closing brush as inverse of Opening brush
Allow covering revealed darkness again during darkened scene sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:21:50 +08:00
Ivan Fontosh 0f01950cc1 feat(effects): add Closing brush as inverse of Opening brush
Allow covering revealed darkness again during darkened scene sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:21:40 +08:00
Ivan Fontosh cc50e64e21 feat(players): NPC disposition types and launch-with-players session tokens
Add Hostile/Neutral/Friendly ring types with session-only inactive overrides on control, plus launch-with-players flow and live player tokens on the map.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:11:01 +08:00
Ivan Fontosh 101f595bac feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:38:35 +08:00
Ivan Fontosh a3a03eb9e3 docs(help): remove NPC scene-binding wording from instructions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:06:51 +08:00
Ivan Fontosh e08f5ef550 feat(npcs): drop binding; select NPCs on storyline export
Remove storyline/scene NPC binding and export chosen NPCs with relations/groups. Harden export modal so a missing npcs payload no longer blacks out the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:05:09 +08:00
Ivan Fontosh 04c75cd725 fix(npcs): stop TipTap crash black screen in secondary windows
Guard TipTap getHTML under StrictMode, wrap secondary window roots in WindowErrorBoundary, and add stability regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 09:10:47 +08:00
Ivan Fontosh e687303c57 fix(npcs): faster editor open, save progress, black screen crash
Warm and show the NPC window sooner, lazy-load ReactFlow/TipTap, overlay save progress like materials, and fix the undefined controlStyles crash after creating an NPC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 08:50:05 +08:00
Ivan Fontosh c7bf7cf449 fix(ui): polish editor layouts and confirm scene delete
Stretch key action buttons, center empty audio/image pickers, and require confirmation before deleting a scene.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:35:07 +08:00
Ivan Fontosh a2b418b78a feat(help): traps/tokens/grid sections and clickable cross-links
Add instruction pages for traps, non-player tokens, and grid generator, and turn section mentions into in-modal navigation links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:13:53 +08:00
Ivan Fontosh f270812219 feat(tokens): app-local non-player tokens with session moves and UI polish
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:04:12 +08:00
Ivan Fontosh bdeb64e356 feat(scene): battle grid overlay with color and trap VFX stacking
Add configurable square/hex grid in the scene editor (persisted and shown on control/presentation) and keep trap activation effects above trap icons.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 06:47:59 +08:00
Ivan Fontosh 4aa0f257d5 feat(materials): show save progress overlay while optimizing images
Block the editor with a spinner and percent stages during material upsert so long image optimization is visible and non-interactive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:50:35 +08:00
Ivan Fontosh 9d82e74272 fix: control UX — no default tool, launch loader, NPC layout
Deselect effect tools by default and on re-click, block the editor during session launch, and fix NPC overlay/window empty-state layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:41:27 +08:00
Ivan Fontosh fed5674468 feat(traps): activation VFX/SFX, explosion effect, and help section
Wire mimic/pit/arrow/laser media on activate, poison/explosion via effects, and document the scene editor and traps in Instructions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 12:35:22 +08:00
Ivan Fontosh 02d73ddf81 feat: scene traps, material legends, and scene editor window
Add trap overlays with session state, material legend editor/panel, and a dedicated scene editor window wired through IPC and project persistence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:29:43 +08:00
     Фонтош Иван Сергеевич d3b1c4660d fix(test): align locale normalize test with system infer path
Compare blank/invalid locale normalization against inferEditorLocaleFromSystem()
so the test passes consistently when Node exposes navigator.languages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 22:00:28 +08:00
Ivan Fontosh eb127c11c2 feat(scene): shared zoom/pan for control and presentation
Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 14:08:19 +08:00
Ivan Fontosh 2c0e6fbf09 feat(effects): extinguish fire with water/rain and ambient SFX volume
Water and rain erase fire under a tight brush hit radius; add looping fire/rain ambients and an Effects sound slider that also scales one-shot effect SFX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 13:12:41 +08:00
Ivan Fontosh a797162f16 perf: trim session IPC hot paths and fix clear-effects idle render
Skip full project broadcast on graph/NPC position commits, send session.stateChanged only to consumer windows, and force one Pixi render before stopping the idle ticker so Clear effects actually clears the canvas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:32:03 +08:00
Ivan Fontosh d9fbecf5a7 perf: cut ControlApp re-renders and lazy-load VFX packs
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:58:16 +08:00
Ivan Fontosh 32a5479086 fix(license): bind deviceId to physical machine, not OS user
Use OS machine identifiers (Windows MachineGuid, macOS IOPlatformUUID, Linux machine-id) hashed as deviceId so all accounts on one PC share one license slot. Keep legacy userData/device.id for migration and retire it on re-activation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:39:46 +08:00
Ivan Fontosh c1c332364c feat(materials): free rotate session overlays and show save progress
Match NPC drag-to-rotate on the material frame during playback, and change the add/edit Save button to Saving… while import runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 14:56:42 +08:00
Ivan Fontosh 0cab7ce7ca feat(npcs): free rotate overlays and drop session zoom tools
Drag the top-edge handle to rotate the whole frame with the avatar; remove unused zoom controls from the session NPC window.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 14:22:46 +08:00
Ivan Fontosh 1829191410 feat(npcs): show multiple character overlays in session playback
Toggle NPCs independently from the characters window, keep one shared dim/close-all, and scroll descriptions for all selected characters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:23:19 +08:00
Ivan Fontosh f4c0ac1438 feat(npcs): add groups, storyline bindings, and Foundry import
Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 13:19:22 +08:00
Ivan Fontosh 37ba855faf feat(npcs): add campaign NPCs with relation graph and session overlay
Add a dedicated NPC editor window, directed relations, control/presentation avatar overlay, and ru/en help for the new section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:13:58 +08:00
Ivan Fontosh 61875be857 feat(materials): add campaign materials overlay for sessions
Let GMs manage and show images over the scene from the editor and control panel, with zoom tools, help docs, and full ru/en i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 11:05:38 +08:00
Ivan Fontosh 195d4be086 fix(editor): polish project picker and scene description UX
Validate unique campaign names, add search, flip menus at scroll end, drop TipTap links, and scroll long description previews.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 19:04:02 +08:00
Ivan Fontosh 8d73f7744e chore: bump to 1.0.26 and allow graph zoom down to 10%
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 11:42:33 +08:00
Ivan Fontosh e731ddb1c0 fix(i18n): rename effects cleanup label and update description help
Rename the effects subgroup from Tools to Cleanup/Очистка and document the description tools and viewer in the instructions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 10:19:16 +08:00
Ivan Fontosh cfa3959fb3 feat(scenes): rich scene descriptions and control viewer window
Add TipTap editing in the editor and an Electron window on the control panel to read the current scene description.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 09:49:40 +08:00
Ivan Fontosh 35a6e979eb feat(editor): DnD сцен в список, порядок списка и правки импорта
Можно перетащить изображения/видео в колонку сцен: создаются новые сцены
с превью и названием из файла, с полноэкранным прогрессом и отчётом о
пропущенных файлах.

Добавлен ручной порядок сцен в списке (sceneListOrder) с сохранением в
проект; перетаскивание карточек отделено от импорта файлов. Во время
сортировки снова работает скролл колесом.

Исправлен залипающий disabled у «+ Новая сцена» после создания сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:32:50 +08:00
Ivan Fontosh b7e6ff6915 feat(editor): drag-and-drop для аудио и превью сцены
Добавлена загрузка файлов перетаскиванием в блоки «Аудио игры», «Аудио сцены»
и «Превью сцены»: подсветка зоны, подсказки, фильтрация по типу, импорт без диалога.

IPC и main принимают пути файлов напрямую; для Electron 41 пути получаются через
webUtils.getPathForFile в preload (File.path в renderer больше недоступен).

Улучшен dev-запуск: Vite слушает 127.0.0.1, ожидание готовности по localhost.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 19:22:51 +08:00
Ivan Fontosh c8ab9dd567 feat(editor): побочные сюжетные линии и импорт/экспорт линий
Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на
пульте: визуальное выделение компонент графа, запрет недопустимых связей
между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий.

Реализован partial export/import сюжетных линий:
- экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json;
- импорт в открытый проект с выбором линий, разрешением конфликтов названий
  сцен (одна модалка на операцию) и отчётом о результате;
- новое окно источника импорта: «Из проекта» (dropdown, без текущего) или
  «Из файла»; на главном экране — только полный импорт из файла.

Исправлены гонки при открытии/закрытии проекта (сериализация open/close в
main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и
старт Electron в dev после готовности Vite.

Добавлены unit-тесты для lineage, export/import и контрактов zipStore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:47:28 +08:00
Ivan Fontosh de9190959c feat(editor): add Run help hint and rename from project list
Show a help icon when Run is disabled, open the relevant instruction section
on click, and position its tooltip below-left. Add Rename project to the home
screen project card menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 22:45:19 +08:00
Ivan Fontosh 10bb7013e6 feat(editor): show scene preview immediately and optimize in background
Return control after copying the original asset, then run image optimization
and thumbnail generation in the background with IPC progress updates.
Block duplicate preview uploads and scene creation while requests are in flight.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 22:38:51 +08:00
Ivan Fontosh 83a326b0b9 docs(legal): expand EULA for RF compliance and bump version to 2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 22:03:02 +08:00
Ivan Fontosh 089da3cae0 feat(effects): migrate battle effects to VFX
- add WebP frame-sequence assets for lightning, electric accent, fire, sunbeam, poison, rain, water, and rounded fog effects
- replace procedural lightning rendering with VFX frame playback
- align lightning impact to the click point
- remove the full-screen lightning blur/flash overlay
- add electric accent playback for lightning impact dispersion
- keep lightning scorch marks as the persistent ground trace after the action effect
- consolidate the lightning tool into a single public `Молния` action
- replace procedural fire with the VFX fire implementation
- remove the separate `Огонь 2` tool and keep the VFX fire under `Огонь`
- keep VFX fire brightness fixed at full intensity
- preserve fire as a field effect with brush placement and eraser support
- replace procedural sunbeam with the Pulse Discharge VFX implementation
- remove the separate `Луч света 2` tool and keep the VFX sunbeam under `Луч света`
- align sunbeam impact to the click point instead of the raw frame center
- remove the full-screen sunbeam flash overlay
- add Dust Burst VFX playback for poison cloud
- tint poison smoke green
- keep the skull glyph rendered above the poison smoke
- align poison smoke bottom to the click point
- remove the old procedural poison cloud implementation
- remove the separate `Яд 2` tool and keep the VFX poison under `Яд`
- replace procedural rain strokes with medium-rain VFX frame playback
- recolor rain frames to gray/light-gray so rain no longer renders black
- preserve rain as a field effect with brush placement and eraser support
- replace static water fill with generated animated water VFX frames
- render water VFX through the existing stroke mask so water keeps the painted brush shape
- preserve water as a field effect with brush placement and eraser support
- replace procedural fog texture generation with Smokey Atmosphere VFX frames
- generate rounded fog VFX frames with soft alpha falloff at the edges
- size each fog VFX stamp to the brush diameter
- remove per-stamp fog drift and rotation so fog elements stay fixed in place
- keep fog animation limited to internal frame playback
- preload VFX frame textures for smoother first use
- cache VFX textures per Pixi instance to avoid duplicate decoding
- filter expired action instances before node synchronization to stop old effects from flashing back during rapid casts
- add brush-stroke erasing for field effects using the same model as scene darkness reveal
- keep whole-instance erasing for action effects
- update effect hit tests for the consolidated VFX action effects
- update effect store pruning for renamed and consolidated action effects
- update control-panel buttons for consolidated fire, sunbeam, poison, and VFX-backed field effects
- update editor localization for removed and renamed effect tools
- add tests for field-effect brush erasing
- update eraser hit-test coverage for VFX-backed effects
- update effects store lifetime pruning tests for consolidated poison
- update control-panel tests for consolidated effect buttons
- include the field-effect eraser test in the default test script

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 21:20:43 +08:00
Ivan Fontosh f5240d1623 docs(help): improve instructions for darkness and project delete
Add scene darkness help text, format numbered lists on separate lines, and describe the correct project deletion flow via the card menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:50:05 +08:00
Ivan Fontosh 4631a1bece 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>
2026-07-02 19:41:26 +08:00
Ivan Fontosh d54e9ed02d docs(help): document Start from this scene in instructions
Add RU/EN help text for launching a session from any graph node via the context menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:15:51 +08:00
Ivan Fontosh ca0cf9c0ce fix(control): improve storyline history and graph launch
Append history on revisit instead of truncating, highlight the latest duplicate entry, add Start from this scene to the graph context menu, and keep storyline state isolated from project graph edits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:14:01 +08:00
Ivan Fontosh 657e3c862e feat(effects): add Darkness action effect like freeze
Adds a fullscreen darkness vignette on click and a permanent shadow spot with a black radial gradient (40% edge, 100% center). Includes control panel UI, i18n, eraser support, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:37:06 +08:00
Ivan Fontosh 97c77a025a Fix typo in Russian EULA license grant wording.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 00:44:13 +08:00
Ivan Fontosh 42b067615d Document fixed and period license key expiry on the server.
Client activation flow is unchanged; exp in the token remains the source of truth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 22:46:11 +08:00
     Фонтош Иван Сергеевич 0c45d58359 chore: bump version to 1.0.25
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:52:47 +08:00
Ivan Fontosh 1eed2b38f8 docs(help): rewrite Instructions in friendlier RU/EN with clear steps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:48:57 +08:00
Ivan Fontosh 60ef837e1c chore: migrate production URLs to ttrpgplayer.ru (v1.0.24)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 15:59:37 +08:00
Ivan Fontosh 374e041321 fix(editor): delete graph edges via right-click context menu
Remove accidental edge deletion on click and document the new graph link workflow in help and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 14:26:14 +08:00
Ivan Fontosh 3acb89c80d chore: bump version to 1.0.23
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 14:13:29 +08:00
Ivan Fontosh 8eb88399a3 feat(editor): add About menu with app info and user guide
Add header menu with About and Instructions modals so GMs can read product details and step-by-step help for all app features.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 14:09:23 +08:00
     Фонтош Иван Сергеевич a96e1f8465 chore: bump version to 1.0.22
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 14:56:21 +08:00
     Фонтош Иван Сергеевич 80103a00e7 fix(mac): updater progress UI and skip redundant download
Show update stage in the modal, forward electron-updater progress over IPC,
and install immediately when the build is already cached. Rename window titles to TTRPG - *.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 11:07:19 +08:00
     Фонтош Иван Сергеевич b017155eaf chore: bump version to 1.0.21
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 10:22:02 +08:00
Ivan Fontosh 5706355c5f fix(mac): prevent updater hang on manual install
Bump to 1.0.20. Disable autoDownload during manual check and fix
Squirrel.Mac quitAndInstall race on darwin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 08:24:52 +08:00
     Фонтош Иван Сергеевич 428fa09224 fix 2026-05-18 10:31:40 +08:00
Ivan Fontosh 1cda87fe13 fix error and new docs 2026-05-18 10:16:48 +08:00
Ivan Fontosh 10de99bb06 fix error and new docs 2026-05-18 10:06:47 +08:00
Ivan Fontosh 0ae3c39333 fix 2026-05-18 09:48:09 +08:00
Ivan Fontosh d07dcae626 Merge branch 'main' of https://git.mailib.ru/ifontosh/DndGamePlayer 2026-05-18 09:44:39 +08:00
Ivan Fontosh dd0dd646f6 save 2026-05-18 09:44:22 +08:00
     Фонтош Иван Сергеевич 8ec830cdb5 fiz mac build 2026-05-18 09:22:35 +08:00
Ivan Fontosh 02b3131f19 фикс 2026-05-18 08:52:39 +08:00
Ivan Fontosh cfa067519d фикс 2026-05-18 08:35:36 +08:00
Ivan Fontosh 4e5d320c36 фикс 2026-05-18 08:26:43 +08:00
Ivan Fontosh 411ac634f4 chore: release v1.0.18 2026-05-18 01:13:18 +08:00
Ivan Fontosh ece48fe53d chore: release v1.0.17 2026-05-18 01:10:07 +08:00
Ivan Fontosh 744ead383d фикс 2026-05-18 01:09:04 +08:00
Ivan Fontosh 9f82a541fc chore(release): point updater feed at updates.mailib.ru (v1.0.16)
Set build.publish.url to the production HTTPS feed, bump version to 1.0.16, add publish-to-updates.ps1 for VPS upload, and normalize Linux AppImage names from x86_64 to x64 after pack:linux.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:55:53 +08:00
Ivan Fontosh 963a1f0790 fix 2026-05-17 23:20:37 +08:00
Ivan Fontosh 394b42e845 fix 2026-05-17 22:04:00 +08:00
Ivan Fontosh 6204359330 fix 2026-05-17 21:55:52 +08:00
Ivan Fontosh 7c858ba633 feat(phase1): rebrand to TTRPG Player and drop Git updates feed
Rename product to TTRPG Player (TTRPGPlayer / com.ttrpgplayer.app), use .ttrpg.zip for new saves while keeping .dnd.zip import, accept TTRPG- and DND- license keys on client, and remove sync-update-feed plus CI push to DndGamePlayerUpdates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 20:56:14 +08:00
Ivan Fontosh 2c03921d23 docs: updates repo size, GC, and 1.5 GiB expectations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:18:30 +08:00
Ivan Fontosh 285a1a9667 fix(update): disable differential download for Gitea generic feed (Range 400)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:16:33 +08:00
Ivan Fontosh 0e14180044 chore: release 1.0.15
Release / release (push) Successful in 8m28s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:00:40 +08:00
Ivan Fontosh cd3ba5fe07 fix(ci): prune old DNDGamePlayer artifacts from updates feed before copy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:53:25 +08:00
Ivan Fontosh 26f8a81631 fix(ci): more git push retries for feed; document HTTP 500 and temp branch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:48:39 +08:00
Ivan Fontosh 2dc7015f53 chore: release 1.0.14
Release / release (push) Failing after 10m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:34:26 +08:00
Ivan Fontosh 0eadfdce30 chore: release 1.0.13
Release / release (push) Successful in 8m41s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:16:32 +08:00
Ivan Fontosh 7e7827224d fix(license): optional file fallback when safeStorage unavailable (WSL)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:14:08 +08:00
Ivan Fontosh 8fa8467db7 fix(ci): stream update feed squash push
Release / release (push) Successful in 7m10s
Split the squashed updates feed upload through a temporary branch so large installers are pushed in smaller packs, then publish the completed updates branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 14:14:54 +08:00
623 changed files with 41358 additions and 3287 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
-150
View File
@@ -1,150 +0,0 @@
# Сборка по тегу v* и выкладка в публичный репо (ветка updates).
#
# Метки runs-on = labels твоих act_runner (Админка Gitea → Действия → Раннеры).
# Не используем windows-latest/macos-latest — это только GitHub-hosted.
# По умолчанию: один job на ubuntu-22.04 — Win (Wine+NSIS), Linux AppImage (x64+arm64), sync в updates без затирания других ОС.
#
# Один job без actions/upload-artifact: официальный upload-artifact@v4 с GitHub на Gitea
# падает (GHESNotSupportedError). Сборка и sync-update-feed идут в одном окружении.
#
# Secrets: DND_UPDATE_FEED_URL, DND_UPDATES_SERVER, UPDATES_REPO, DND_UPDATES_PUSH_TOKEN — docs/GITEA_AUTO_UPDATE.md
name: Release
on:
push:
tags:
- 'v*'
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
jobs:
release:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
# Не включаем i386 / wine32: на Debian с репами только amd64 (nginx, sury, …)
# apt ломается на цепочке libgphoto2/libgd:i386. NSIS — нативный пакет `nsis`;
# electron-builder подхватывает makensis; wine64 — для win-утилит (rcedit и т.п.).
# Пакет `wine` (мета) тянет wine32 → снова i386. electron-builder ищет именно `wine` в PATH.
- name: Зависимости Win-сборки на Linux (amd64, без multiarch i386)
shell: bash
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
nsis \
wine64
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
W64="$(command -v wine64 2>/dev/null || true)"
if [[ -z "$W64" || ! -x "$W64" ]]; then
for p in /usr/bin/wine64 /usr/lib/wine/wine64; do
if [[ -x "$p" ]]; then W64="$p"; break; fi
done
fi
if [[ -z "${W64:-}" || ! -x "$W64" ]]; then
echo "wine64 binary not found after apt install wine64" >&2
dpkg -L wine64 2>/dev/null | head -80 >&2 || true
exit 1
fi
printf '%s\n' '#!/bin/sh' "exec $W64 \"\$@\"" | sudo tee /usr/local/bin/wine >/dev/null
sudo chmod +x /usr/local/bin/wine
wine --version
- name: Версия из тега
shell: bash
run: |
TAG="${GITHUB_REF_NAME:-${GITEA_REF_NAME:-}}"
VERSION="${TAG#v}"
npm version "$VERSION" --allow-same-version --no-git-tag-version
- run: npm ci
- run: npm run build
# Linux AppImage (x64 + arm64) до подмешивания win32-sharp в node_modules.
- name: Зависимости Linux AppImage и кросс-сборка arm64 на amd64
shell: bash
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
qemu-user-static \
binfmt-support \
desktop-file-utils \
squashfs-tools
- name: electron-builder (linux AppImage x64, arm64)
shell: bash
env:
DND_UPDATE_FEED_URL: ${{ secrets.DND_UPDATE_FEED_URL }}
run: |
set -euo pipefail
if [[ -z "${DND_UPDATE_FEED_URL:-}" ]]; then
echo "Secret DND_UPDATE_FEED_URL is not set (URL со слэшем в конце)" >&2
exit 1
fi
npx electron-builder --linux AppImage --x64 --arm64 --publish never \
--config.publish.provider=generic \
--config.publish.url="${DND_UPDATE_FEED_URL}"
# Не используем `npm install`: на Linux npm падает с EBADPLATFORM для win32-пакета.
- name: sharp (@img/sharp-win32-x64) для Windows-артефакта при сборке на Linux
shell: bash
run: |
set -euo pipefail
tmp="$(mktemp -d)"
npm pack @img/sharp-win32-x64@0.34.5 --pack-destination "$tmp"
mkdir -p node_modules/@img/sharp-win32-x64
tar -xzf "$tmp/img-sharp-win32-x64-0.34.5.tgz" -C "$tmp"
cp -a "$tmp/package/." node_modules/@img/sharp-win32-x64/
test -f node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node
rm -rf "$tmp"
- name: electron-builder (win)
shell: bash
env:
DND_UPDATE_FEED_URL: ${{ secrets.DND_UPDATE_FEED_URL }}
run: |
set -euo pipefail
if [[ -z "${DND_UPDATE_FEED_URL:-}" ]]; then
echo "Secret DND_UPDATE_FEED_URL is not set (URL со слэшем в конце)" >&2
exit 1
fi
npx electron-builder --win --publish never \
--config.publish.provider=generic \
--config.publish.url="${DND_UPDATE_FEED_URL}"
# Артефакты читает sync напрямую из release/ (без дублирующих _win/_linux — меньше ENOSPC).
- name: Пустой каталог mac (пока нет сборки mac в CI)
run: mkdir -p _mac
- name: Push в публичный репозиторий updates
shell: bash
env:
DND_UPDATES_SERVER: ${{ secrets.DND_UPDATES_SERVER }}
UPDATES_REPO: ${{ secrets.UPDATES_REPO }}
DND_UPDATES_PUSH_TOKEN: ${{ secrets.DND_UPDATES_PUSH_TOKEN }}
ARTIFACT_WIN: ${{ github.workspace }}/release
ARTIFACT_MAC: ${{ github.workspace }}/_mac
ARTIFACT_LINUX: ${{ github.workspace }}/release
DND_FEED_TMP_ROOT: ${{ github.workspace }}/.dnd-feed-tmp
DND_UPDATES_SQUASH_HISTORY: '1'
GIT_COMMIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
mkdir -p "${{ github.workspace }}/.dnd-feed-tmp" "${{ github.workspace }}/_mac"
node scripts/sync-update-feed.mjs
# Когда появится macOS-раннер: отдельный job build-macos, копирование eb-mac в _mac перед sync
# или расширить шаг «Каталог артефактов»; сейчас всё в одном job `release`.
#
# build-macos:
# runs-on: macos-14
# steps: ...
+3
View File
@@ -17,3 +17,6 @@ Thumbs.db
.vscode/*
!.vscode/extensions.json
*.tsbuildinfo
test-results/
playwright-report/
e2e/fixtures/sample.png
+1
View File
@@ -0,0 +1 @@
22.22.3
+25 -1
View File
@@ -3,6 +3,11 @@ import test from 'node:test';
import { EffectsStore } from './effectsStore';
void test('defaultTool: при старте инструмент не выбран', () => {
const store = new EffectsStore();
assert.equal(store.getState().tool.tool, 'none');
});
void test('pruneExpired: лёд не удаляется по времени', () => {
const store = new EffectsStore();
const createdAtMs = Date.now() - 365 * 24 * 60 * 60 * 1000;
@@ -64,7 +69,7 @@ void test('pruneExpired: луч света удаляется после lifetim
assert.equal(store.getState().instances.length, 0);
});
void test('pruneExpired: облако яда удаляется после lifetime', () => {
void test('pruneExpired: яд удаляется после lifetime', () => {
const store = new EffectsStore();
store.dispatch({
kind: 'instance.add',
@@ -82,3 +87,22 @@ void test('pruneExpired: облако яда удаляется после lifet
assert.equal(store.pruneExpired(), true);
assert.equal(store.getState().instances.length, 0);
});
void test('pruneExpired: взрыв удаляется после lifetime', () => {
const store = new EffectsStore();
store.dispatch({
kind: 'instance.add',
instance: {
id: 'ex_test',
type: 'explosion',
seed: 1,
createdAtMs: Date.now() - 20_000,
at: { x: 0.5, y: 0.5 },
radiusN: 0.08,
intensity: 1,
lifetimeMs: 4200,
},
});
assert.equal(store.pruneExpired(), true);
assert.equal(store.getState().instances.length, 0);
});
+34 -6
View File
@@ -1,5 +1,6 @@
import crypto from 'node:crypto';
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
function nowMs(): number {
@@ -7,7 +8,12 @@ function nowMs(): number {
}
function defaultTool(): EffectToolState {
return { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
return { tool: 'none', radiusN: 0.08, intensity: 0.6 };
}
/** Сброс выбранного инструмента (при старте сессии). */
export function effectsDefaultTool(): EffectToolState {
return defaultTool();
}
export class EffectsStore {
@@ -35,7 +41,7 @@ export class EffectsStore {
dispatch(event: EffectsEvent): EffectsState {
const s = this.state;
const next: EffectsState = applyEvent(s, event);
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
this.state = next;
return next;
}
@@ -46,14 +52,20 @@ export class EffectsStore {
const before = this.state.instances.length;
const kept = this.state.instances.filter((i) => {
// Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI).
if (i.type === 'ice') return true;
if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud') {
if (i.type === 'ice' || i.type === 'shadow') return true;
if (
i.type === 'lightning' ||
i.type === 'sunbeam' ||
i.type === 'poisonCloud' ||
i.type === 'explosion' ||
i.type === 'darkness'
) {
return now - i.createdAtMs < i.lifetimeMs;
}
if (i.type === 'scorch') {
return now - i.createdAtMs < i.lifetimeMs;
}
if (i.type === 'fog' || i.type === 'water') {
if (i.type === 'fog' || i.type === 'fire' || i.type === 'rain' || i.type === 'water') {
if (i.lifetimeMs === null) return true;
return now - i.createdAtMs < i.lifetimeMs;
}
@@ -74,7 +86,11 @@ export class EffectsStore {
}
}
function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
function applyEvent(
state: EffectsState,
event: EffectsEvent,
makeId: (prefix: string) => string,
): EffectsState {
const bump = (patch: Omit<EffectsState, 'revision' | 'serverNowMs'>): EffectsState => ({
...patch,
revision: state.revision + 1,
@@ -89,6 +105,18 @@ function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
return bump({ ...state, instances: [...state.instances, event.instance] });
case 'instance.remove':
return bump({ ...state, instances: state.instances.filter((i) => i.id !== event.id) });
case 'field.erase':
return bump({
...state,
instances: applyFieldEffectEraserStroke(
state.instances,
event.points,
event.radiusN,
makeId,
event.types,
event.hitMode ?? 'inclusive',
),
});
default: {
// Exhaustiveness
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+101
View File
@@ -0,0 +1,101 @@
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, mode: 'cover' },
});
assert.ok(store.getState().draft);
assert.equal(store.getState().draft?.mode, 'cover');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.1, y: 0.1, tMs: 1 }],
radiusN: 0.05,
mode: 'cover',
},
});
assert.equal(store.getState().draft, null);
assert.equal(store.getState().strokes.length, 1);
});
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'open1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
mode: 'reveal',
},
});
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'close1',
seed: 2,
createdAtMs: 200,
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
radiusN: 0.08,
mode: 'cover',
},
});
assert.equal(store.getState().strokes.length, 2);
assert.equal(store.getState().strokes[1]?.mode, 'cover');
store.switchScene('scene_b', true);
store.switchScene('scene_a', true);
assert.equal(store.getState().strokes.length, 2);
assert.equal(store.getState().strokes[1]?.mode, 'cover');
});
+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();
}
}
}
}
+242
View File
@@ -0,0 +1,242 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { ClassicLevel } from 'classic-level';
import type {
FoundryActorDoc,
FoundryAdventureDoc,
FoundryFolderDoc,
FoundryJournalDoc,
FoundryLoadedDocuments,
FoundryPlaylistDoc,
FoundrySceneDoc,
} from '../../shared/foundry/foundryTypes';
function isRecord(v: unknown): v is Record<string, unknown> {
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
}
function asDocArray<T>(docs: unknown[]): T[] {
return docs.filter((d) => isRecord(d) && typeof d._id === 'string' && typeof d.name === 'string') as T[];
}
/** NeDB: по строке JSON на линию (игнор пустых / битых). */
export async function readNedbDocuments(filePath: string): Promise<Record<string, unknown>[]> {
const raw = await fs.readFile(filePath, 'utf8');
const out: Record<string, unknown>[] = [];
for (const line of raw.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed: unknown = JSON.parse(trimmed);
if (isRecord(parsed) && typeof parsed._id === 'string') out.push(parsed);
} catch {
// skip broken line
}
}
return out;
}
function isLevelDbDir(entries: { name: string; isFile(): boolean; isDirectory(): boolean }[]): boolean {
const names = new Set(entries.map((e) => e.name));
return (
names.has('CURRENT') ||
names.has('LOG') ||
[...names].some((n) => n.endsWith('.ldb') || n.startsWith('MANIFEST-'))
);
}
/** Читает primary documents из LevelDB-папки Foundry (ключи `!collection!id`). */
export async function readLevelDbDocuments(dirPath: string): Promise<Record<string, unknown>[]> {
const db = new ClassicLevel(dirPath, {
keyEncoding: 'utf8',
valueEncoding: 'json',
createIfMissing: false,
});
try {
const out: Record<string, unknown>[] = [];
for await (const [key, value] of db.iterator()) {
if (typeof key !== 'string') continue;
const parts = key.split('!');
// "", collection, id
if (parts.length < 3) continue;
const collection = parts[1] ?? '';
if (!collection || collection.includes('.')) continue; // embedded
if (!isRecord(value)) continue;
if (typeof value._id !== 'string') continue;
out.push(value);
}
return out;
} finally {
await db.close().catch(() => undefined);
}
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function readCollectionDocs(baseDir: string, collection: string): Promise<Record<string, unknown>[]> {
const candidates = [
path.join(baseDir, collection),
path.join(baseDir, `${collection}.db`),
path.join(baseDir, 'data', collection),
path.join(baseDir, 'data', `${collection}.db`),
];
for (const candidate of candidates) {
if (!(await pathExists(candidate))) continue;
const st = await fs.stat(candidate);
if (st.isFile() && candidate.endsWith('.db')) {
return readNedbDocuments(candidate);
}
if (st.isDirectory()) {
const entries = await fs.readdir(candidate, { withFileTypes: true });
if (isLevelDbDir(entries)) {
return readLevelDbDocuments(candidate);
}
}
}
return [];
}
export async function readPackDocuments(
packAbsPath: string,
documentType: string,
): Promise<Record<string, unknown>[]> {
if (!(await pathExists(packAbsPath))) return [];
const st = await fs.stat(packAbsPath);
if (st.isFile() && packAbsPath.endsWith('.db')) {
return readNedbDocuments(packAbsPath);
}
if (st.isDirectory()) {
const entries = await fs.readdir(packAbsPath, { withFileTypes: true });
if (isLevelDbDir(entries)) {
return readLevelDbDocuments(packAbsPath);
}
// Иногда path указывает на папку с .db внутри.
const nestedDb = path.join(packAbsPath, `${path.basename(packAbsPath)}.db`);
if (await pathExists(nestedDb)) return readNedbDocuments(nestedDb);
}
void documentType;
return [];
}
function mergeById<T extends { _id: string }>(lists: T[][]): T[] {
const map = new Map<string, T>();
for (const list of lists) {
for (const doc of list) {
if (!map.has(doc._id)) map.set(doc._id, doc);
}
}
return [...map.values()];
}
function flattenAdventureDocs(adventures: FoundryAdventureDoc[]): {
scenes: FoundrySceneDoc[];
actors: FoundryActorDoc[];
playlists: FoundryPlaylistDoc[];
journals: FoundryJournalDoc[];
folders: FoundryFolderDoc[];
} {
const scenes: FoundrySceneDoc[] = [];
const actors: FoundryActorDoc[] = [];
const playlists: FoundryPlaylistDoc[] = [];
const journals: FoundryJournalDoc[] = [];
const folders: FoundryFolderDoc[] = [];
for (const adv of adventures) {
if (Array.isArray(adv.scenes)) scenes.push(...asDocArray<FoundrySceneDoc>(adv.scenes));
if (Array.isArray(adv.actors)) actors.push(...asDocArray<FoundryActorDoc>(adv.actors));
if (Array.isArray(adv.playlists)) playlists.push(...asDocArray<FoundryPlaylistDoc>(adv.playlists));
if (Array.isArray(adv.journal)) journals.push(...asDocArray<FoundryJournalDoc>(adv.journal));
if (Array.isArray(adv.folders)) {
for (const f of adv.folders) {
if (f && typeof f === 'object' && typeof (f as FoundryFolderDoc)._id === 'string') {
folders.push(f as FoundryFolderDoc);
}
}
}
}
return { scenes, actors, playlists, journals, folders };
}
function asFolderArray(docs: unknown[]): FoundryFolderDoc[] {
return docs.filter(
(d): d is FoundryFolderDoc =>
Boolean(d) &&
typeof d === 'object' &&
typeof (d as FoundryFolderDoc)._id === 'string' &&
typeof (d as FoundryFolderDoc).name === 'string',
);
}
export async function loadWorldDocuments(rootDir: string): Promise<FoundryLoadedDocuments> {
const [scenesRaw, actorsRaw, playlistsRaw, journalsRaw, adventuresRaw, foldersRaw] =
await Promise.all([
readCollectionDocs(rootDir, 'scenes'),
readCollectionDocs(rootDir, 'actors'),
readCollectionDocs(rootDir, 'playlists'),
readCollectionDocs(rootDir, 'journal'),
readCollectionDocs(rootDir, 'adventures'),
readCollectionDocs(rootDir, 'folders'),
]);
const adventures = asDocArray<FoundryAdventureDoc>(adventuresRaw);
const embedded = flattenAdventureDocs(adventures);
return {
scenes: mergeById([asDocArray<FoundrySceneDoc>(scenesRaw), embedded.scenes]),
actors: mergeById([asDocArray<FoundryActorDoc>(actorsRaw), embedded.actors]),
playlists: mergeById([asDocArray<FoundryPlaylistDoc>(playlistsRaw), embedded.playlists]),
journals: mergeById([asDocArray<FoundryJournalDoc>(journalsRaw), embedded.journals]),
adventures,
folders: mergeById([asFolderArray(foldersRaw), embedded.folders]),
};
}
export async function loadModuleDocuments(
rootDir: string,
packs: { path: string; type: string }[],
): Promise<FoundryLoadedDocuments> {
const scenes: FoundrySceneDoc[][] = [];
const actors: FoundryActorDoc[][] = [];
const playlists: FoundryPlaylistDoc[][] = [];
const journals: FoundryJournalDoc[][] = [];
const adventures: FoundryAdventureDoc[][] = [];
for (const pack of packs) {
const abs = path.isAbsolute(pack.path) ? pack.path : path.join(rootDir, pack.path);
// path в module.json иногда с `./` и иногда ещё со старым `.db`
const variants = [abs, abs.endsWith('.db') ? abs : `${abs}.db`, abs.replace(/\.db$/u, '')];
let docs: Record<string, unknown>[] = [];
for (const v of variants) {
docs = await readPackDocuments(v, pack.type);
if (docs.length > 0) break;
}
const type = pack.type;
if (type === 'Scene') scenes.push(asDocArray<FoundrySceneDoc>(docs));
else if (type === 'Actor') actors.push(asDocArray<FoundryActorDoc>(docs));
else if (type === 'Playlist') playlists.push(asDocArray<FoundryPlaylistDoc>(docs));
else if (type === 'JournalEntry') journals.push(asDocArray<FoundryJournalDoc>(docs));
else if (type === 'Adventure') adventures.push(asDocArray<FoundryAdventureDoc>(docs));
}
const advMerged = mergeById(adventures);
const embedded = flattenAdventureDocs(advMerged);
return {
scenes: mergeById([...scenes, embedded.scenes]),
actors: mergeById([...actors, embedded.actors]),
playlists: mergeById([...playlists, embedded.playlists]),
journals: mergeById([...journals, embedded.journals]),
adventures: advMerged,
folders: mergeById([embedded.folders]),
};
}
+209
View File
@@ -0,0 +1,209 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { decodeFoundryAssetPath } from '../../shared/foundry/foundryPaths';
import type { FoundryPackageManifest, FoundryPackRef } from '../../shared/foundry/foundryTypes';
import { isSupportedFoundryPackage } from '../../shared/foundry/foundryVersion';
function isRecord(v: unknown): v is Record<string, unknown> {
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
}
async function readJsonFile(filePath: string): Promise<unknown> {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw) as unknown;
}
function parsePacks(raw: unknown): FoundryPackRef[] {
if (!Array.isArray(raw)) return [];
const out: FoundryPackRef[] = [];
for (const item of raw) {
if (!isRecord(item)) continue;
const name = typeof item.name === 'string' ? item.name : '';
const label = typeof item.label === 'string' ? item.label : name;
const packPath = typeof item.path === 'string' ? item.path.replace(/^\.\//u, '') : '';
const type =
typeof item.type === 'string' ? item.type : typeof item.entity === 'string' ? item.entity : '';
if (!packPath || !type) continue;
out.push({ name: name || path.basename(packPath), label: label || name, path: packPath, type });
}
return out;
}
function titleFromManifest(data: Record<string, unknown>, fallbackId: string): string {
if (typeof data.title === 'string' && data.title.trim()) return data.title.trim();
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
return fallbackId;
}
function idFromManifest(data: Record<string, unknown>, dirName: string): string {
if (typeof data.id === 'string' && data.id.trim()) return data.id.trim();
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
return dirName;
}
async function tryParseManifest(dir: string): Promise<FoundryPackageManifest | null> {
const worldPath = path.join(dir, 'world.json');
const modulePath = path.join(dir, 'module.json');
try {
await fs.access(worldPath);
const data = await readJsonFile(worldPath);
if (!isRecord(data)) return null;
const id = idFromManifest(data, path.basename(dir));
const manifest: FoundryPackageManifest = {
kind: 'world',
id,
title: titleFromManifest(data, id),
rootDir: dir,
packs: [],
};
const compatibility = parseCompatibility(data.compatibility);
if (compatibility) manifest.compatibility = compatibility;
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
return manifest;
} catch {
// not a world
}
try {
await fs.access(modulePath);
const data = await readJsonFile(modulePath);
if (!isRecord(data)) return null;
const id = idFromManifest(data, path.basename(dir));
const manifest: FoundryPackageManifest = {
kind: 'module',
id,
title: titleFromManifest(data, id),
rootDir: dir,
packs: parsePacks(data.packs),
};
const compatibility = parseCompatibility(data.compatibility);
if (compatibility) manifest.compatibility = compatibility;
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
return manifest;
} catch {
return null;
}
}
function parseCompatibility(raw: unknown): FoundryPackageManifest['compatibility'] | null {
if (!isRecord(raw)) return null;
const out: NonNullable<FoundryPackageManifest['compatibility']> = {};
if (typeof raw.minimum === 'string') out.minimum = raw.minimum;
if (typeof raw.verified === 'string') out.verified = raw.verified;
if (typeof raw.maximum === 'string') out.maximum = raw.maximum;
return out;
}
/** Ищет world.json / module.json в папке и на 1–2 уровня глубже. */
export async function detectFoundryPackage(rootPath: string): Promise<FoundryPackageManifest> {
const abs = path.resolve(rootPath);
const direct = await tryParseManifest(abs);
if (direct) {
const ver = isSupportedFoundryPackage(direct);
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
return direct;
}
// Архив мог содержать один корневой каталог.
const entries = await fs.readdir(abs, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(abs, e.name));
for (const dir of dirs) {
const found = await tryParseManifest(dir);
if (found) {
const ver = isSupportedFoundryPackage(found);
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
return found;
}
}
// Data/worlds/<id> или Data/modules/<id>
for (const mid of ['worlds', 'modules']) {
const midDir = path.join(abs, mid);
try {
const st = await fs.stat(midDir);
if (!st.isDirectory()) continue;
const children = await fs.readdir(midDir, { withFileTypes: true });
for (const child of children.filter((c) => c.isDirectory())) {
const found = await tryParseManifest(path.join(midDir, child.name));
if (found) {
const ver = isSupportedFoundryPackage(found);
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
return found;
}
}
} catch {
// ignore
}
}
// Data как корень выбранной папки
const dataDir = path.join(abs, 'Data');
try {
const st = await fs.stat(dataDir);
if (st.isDirectory()) {
return await detectFoundryPackage(dataDir);
}
} catch {
// ignore
}
throw new Error(
'Не похоже на пакет Foundry VTT: не найдены world.json или module.json. Выберите папку мира/модуля или архив с ними.',
);
}
/**
* Резолвит путь ассета Foundry относительно корня пакета / Data.
* foundryPath — как в документах: `worlds/id/...`, `modules/id/...` или относительный.
* Поддерживает URL-encoding (`The%20Withered%20Grove%20(day).webp`).
*/
export async function resolveFoundryAssetPath(
manifest: FoundryPackageManifest,
foundryPath: string,
): Promise<string | null> {
const cleaned = decodeFoundryAssetPath(foundryPath);
if (!cleaned) return null;
if (/^https?:\/\//iu.test(cleaned)) return null;
const packagePrefix = manifest.kind === 'world' ? `worlds/${manifest.id}/` : `modules/${manifest.id}/`;
const rawNormalized = foundryPath.trim().replace(/\\/gu, '/');
const pathVariants = cleaned === rawNormalized ? [cleaned] : [cleaned, rawNormalized];
const candidates: string[] = [];
for (const variant of pathVariants) {
const v = variant.replace(/^\/+/u, '');
if (!v) continue;
if (v.toLowerCase().startsWith(packagePrefix.toLowerCase())) {
candidates.push(path.join(manifest.rootDir, v.slice(packagePrefix.length)));
}
candidates.push(path.join(manifest.rootDir, v));
const dataRootGuesses = [
path.resolve(manifest.rootDir, '..', '..'),
path.resolve(manifest.rootDir, '..'),
];
for (const dataRoot of dataRootGuesses) {
candidates.push(path.join(dataRoot, v));
}
const base = path.basename(v);
if (base && base !== v) {
candidates.push(path.join(manifest.rootDir, base));
candidates.push(path.join(manifest.rootDir, 'assets', base));
}
}
for (const c of candidates) {
try {
const st = await fs.stat(c);
if (st.isFile()) return c;
} catch {
// next
}
}
return null;
}
+660
View File
@@ -0,0 +1,660 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
actorPortraitSrc,
extractActorDescriptionHtml,
filterScenesForImport,
journalDescriptionHtml,
planFoundrySceneGraph,
sceneBackgroundSrc,
} from '../../shared/foundry/foundryGraph';
import type {
FoundryFolderDoc,
FoundryLoadedDocuments,
FoundryPackageManifest,
FoundryPlaylistDoc,
FoundrySceneDoc,
} from '../../shared/foundry/foundryTypes';
import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups';
import type {
MediaAsset,
Project,
ProjectNpc,
ProjectNpcGroup,
Scene,
SceneGraphEdge,
SceneGraphNode,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import type { AssetId, GraphNodeId, NpcGroupId, SceneId } from '../../shared/types/ids';
import {
asAssetId,
asGraphNodeId,
asNpcGroupId,
asNpcId,
asProjectId,
asSceneId,
} from '../../shared/types/ids';
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
import { generateScenePreviewThumbnailBytes } from '../project/scenePreviewThumbnail';
import { unzipToDir } from '../project/yauzlProjectZip';
import { getAppSemanticVersion } from '../versionInfo';
import { loadModuleDocuments, loadWorldDocuments } from './foundryDb';
import { detectFoundryPackage, resolveFoundryAssetPath } from './foundryDetect';
export type FoundryImportProgress = {
stage: 'copy' | 'unzip' | 'zip' | 'done';
percent: number;
detail?: string;
};
/** Минимальный 1×1 PNG (серый), если у актёра нет портрета на диске. */
const PLACEHOLDER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
type MediaKind = { type: 'image' | 'video' | 'audio'; mime: string };
function classifyMediaPath(filePath: string): MediaKind | null {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.png':
return { type: 'image', mime: 'image/png' };
case '.jpg':
case '.jpeg':
return { type: 'image', mime: 'image/jpeg' };
case '.webp':
return { type: 'image', mime: 'image/webp' };
case '.gif':
return { type: 'image', mime: 'image/gif' };
case '.bmp':
return { type: 'image', mime: 'image/bmp' };
case '.mp4':
return { type: 'video', mime: 'video/mp4' };
case '.webm':
return { type: 'video', mime: 'video/webm' };
case '.mov':
return { type: 'video', mime: 'video/quicktime' };
case '.mp3':
return { type: 'audio', mime: 'audio/mpeg' };
case '.wav':
return { type: 'audio', mime: 'audio/wav' };
case '.ogg':
return { type: 'audio', mime: 'audio/ogg' };
case '.m4a':
return { type: 'audio', mime: 'audio/mp4' };
case '.aac':
return { type: 'audio', mime: 'audio/aac' };
case '.flac':
return { type: 'audio', mime: 'audio/flac' };
default:
return null;
}
}
function sanitizeFileName(name: string): string {
const cleaned = name
.split('')
.map((ch) => {
const code = ch.charCodeAt(0);
if (code < 32 || '<>:"/\\|?*'.includes(ch)) return '_';
return ch;
})
.join('')
.trim();
return cleaned.length > 0 ? cleaned.slice(0, 180) : 'file';
}
function randomId(): string {
return crypto.randomBytes(16).toString('hex');
}
function buildMediaAsset(
id: AssetId,
kind: MediaKind,
originalName: string,
relPath: string,
sha256: string,
sizeBytes: number,
): MediaAsset {
const createdAt = new Date().toISOString();
const base = { id, mime: kind.mime, originalName, relPath, sha256, sizeBytes, createdAt };
if (kind.type === 'image') return { ...base, type: 'image' };
if (kind.type === 'video') return { ...base, type: 'video' };
return { ...base, type: 'audio' };
}
async function isZipFile(filePath: string): Promise<boolean> {
try {
const st = await fs.stat(filePath);
if (!st.isFile()) return false;
const ext = path.extname(filePath).toLowerCase();
return ext === '.zip' || ext === '.fvtt';
} catch {
return false;
}
}
async function prepareSourceDir(
sourcePath: string,
onProgress?: (p: FoundryImportProgress) => void,
): Promise<{ workDir: string; cleanup: () => Promise<void> }> {
const abs = path.resolve(sourcePath);
if (await isZipFile(abs)) {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ttrpg-foundry-'));
onProgress?.({ stage: 'unzip', percent: 5, detail: 'Распаковка архива Foundry…' });
await unzipToDir(abs, workDir, (done, total) => {
const pct = total > 0 ? 5 + Math.round((done / total) * 25) : 15;
onProgress?.({ stage: 'unzip', percent: Math.min(30, pct), detail: 'Распаковка архива Foundry…' });
});
return {
workDir,
cleanup: async () => {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
const st = await fs.stat(abs);
if (!st.isDirectory()) {
throw new Error('Выберите папку мира/модуля Foundry или архив (.zip / .fvtt).');
}
return {
workDir: abs,
cleanup: () => Promise.resolve(),
};
}
type AssetWriteCtx = {
cacheDir: string;
assets: Record<AssetId, MediaAsset>;
/** foundry path or abs path → assetId */
bySourceKey: Map<string, AssetId>;
};
async function importFileAsAsset(
ctx: AssetWriteCtx,
absPath: string,
opts?: { optimizeImage?: boolean; forceKind?: MediaKind },
): Promise<AssetId | null> {
const key = path.resolve(absPath).toLowerCase();
const existing = ctx.bySourceKey.get(key);
if (existing) return existing;
let kind = opts?.forceKind ?? classifyMediaPath(absPath);
if (!kind) return null;
let buf = await fs.readFile(absPath);
if (kind.type === 'image' && opts?.optimizeImage !== false) {
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (!opt.passthrough && opt.buffer.length > 0) {
buf = Buffer.from(opt.buffer);
kind = { type: 'image', mime: opt.mime };
}
} catch {
// keep original
}
}
const id = asAssetId(randomId());
const orig = path.basename(absPath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const absOut = path.join(ctx.cacheDir, relPath);
await fs.mkdir(path.dirname(absOut), { recursive: true });
await fs.writeFile(absOut, buf);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
ctx.assets[id] = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
ctx.bySourceKey.set(key, id);
return id;
}
async function importPlaceholderAvatar(ctx: AssetWriteCtx, name: string): Promise<AssetId> {
const id = asAssetId(randomId());
const orig = `${sanitizeFileName(name)}_avatar.png`;
const relPath = `assets/${id}_${orig}`;
const absOut = path.join(ctx.cacheDir, relPath);
await fs.mkdir(path.dirname(absOut), { recursive: true });
await fs.writeFile(absOut, PLACEHOLDER_PNG);
const sha256 = crypto.createHash('sha256').update(PLACEHOLDER_PNG).digest('hex');
ctx.assets[id] = buildMediaAsset(
id,
{ type: 'image', mime: 'image/png' },
orig,
relPath,
sha256,
PLACEHOLDER_PNG.length,
);
return id;
}
async function resolveAndImport(
ctx: AssetWriteCtx,
manifest: FoundryPackageManifest,
foundryPath: string | null | undefined,
opts?: { optimizeImage?: boolean },
): Promise<AssetId | null> {
if (!foundryPath?.trim()) return null;
const abs = await resolveFoundryAssetPath(manifest, foundryPath);
if (!abs) return null;
return importFileAsAsset(ctx, abs, opts);
}
function uniqueNpcName(base: string, used: Set<string>): string {
const root = base.trim() || 'NPC';
if (!used.has(root.toLowerCase())) {
used.add(root.toLowerCase());
return root;
}
let i = 2;
while (used.has(`${root} (${String(i)})`.toLowerCase())) i += 1;
const name = `${root} (${String(i)})`;
used.add(name.toLowerCase());
return name;
}
function collectSceneAudioPaths(
scene: FoundrySceneDoc,
playlistsById: Map<string, FoundryPlaylistDoc>,
): string[] {
const paths: string[] = [];
const playlistId = typeof scene.playlist === 'string' ? scene.playlist : null;
if (!playlistId) return paths;
const playlist = playlistsById.get(playlistId);
if (!playlist?.sounds?.length) return paths;
const soundId = typeof scene.playlistSound === 'string' ? scene.playlistSound : null;
const sounds = soundId
? playlist.sounds.filter((s) => s._id === soundId)
: [...playlist.sounds].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
for (const s of sounds) {
if (typeof s.path === 'string' && s.path.trim()) paths.push(s.path.trim());
}
return paths;
}
export type FoundryBuiltProject = {
project: Project;
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
previewSources: { sceneId: SceneId; absPath: string }[];
};
export async function buildProjectFromFoundryDocuments(
manifest: FoundryPackageManifest,
docs: FoundryLoadedDocuments,
cacheDir: string,
onProgress?: (p: FoundryImportProgress) => void,
options?: { projectId?: ReturnType<typeof asProjectId> },
): Promise<FoundryBuiltProject> {
const projectId = options?.projectId ?? asProjectId(randomId());
const now = new Date().toISOString();
const appVer = getAppSemanticVersion();
const name = manifest.title.trim() || manifest.id;
const fileBaseName = `${sanitizeFileName(name)}_${projectId}`;
const ctx: AssetWriteCtx = { cacheDir, assets: {}, bySourceKey: new Map() };
const journalsById = new Map(docs.journals.map((j) => [j._id, j]));
const playlistsById = new Map(docs.playlists.map((p) => [p._id, p]));
const importScenes = filterScenesForImport(docs.scenes);
const graphPlan = planFoundrySceneGraph(importScenes, docs.adventures, docs.journals);
const sceneOrderIds =
graphPlan.orderedSceneIds.length > 0 ? graphPlan.orderedSceneIds : importScenes.map((s) => s._id);
const scenesByFoundryId = new Map(importScenes.map((s) => [s._id, s]));
const foundryToSceneId = new Map<string, SceneId>();
const scenes: Record<SceneId, Scene> = {};
const sceneListOrder: SceneId[] = [];
const previewSources: { sceneId: SceneId; absPath: string }[] = [];
const totalSteps = Math.max(1, sceneOrderIds.length + docs.actors.length);
let step = 0;
for (const foundrySceneId of sceneOrderIds) {
const doc = scenesByFoundryId.get(foundrySceneId);
if (!doc) continue;
step += 1;
onProgress?.({
stage: 'copy',
percent: 30 + Math.round((step / totalSteps) * 50),
detail: `Сцена: ${doc.name}`,
});
const sceneId = asSceneId(`s_${randomId()}`);
foundryToSceneId.set(foundrySceneId, sceneId);
let previewAssetId: AssetId | null = null;
let previewAssetType: 'image' | 'video' | null = null;
let previewThumbAssetId: AssetId | null = null;
const bg = sceneBackgroundSrc(doc);
if (bg) {
const abs = await resolveFoundryAssetPath(manifest, bg);
if (abs) {
const kind = classifyMediaPath(abs);
if (kind?.type === 'image' || kind?.type === 'video') {
previewAssetId = await importFileAsAsset(ctx, abs, { optimizeImage: kind.type === 'image' });
previewAssetType = kind.type;
previewSources.push({ sceneId, absPath: abs });
if (previewAssetId && kind.type === 'image') {
try {
const thumbBytes = await generateScenePreviewThumbnailBytes(abs, 'image');
if (thumbBytes && thumbBytes.length > 0) {
const thumbId = asAssetId(randomId());
const thumbRel = `assets/${thumbId}_preview_thumb.webp`;
await fs.writeFile(path.join(cacheDir, thumbRel), thumbBytes);
ctx.assets[thumbId] = buildMediaAsset(
thumbId,
{ type: 'image', mime: 'image/webp' },
`${sanitizeFileName(doc.name)}_preview_thumb.webp`,
thumbRel,
crypto.createHash('sha256').update(thumbBytes).digest('hex'),
thumbBytes.length,
);
previewThumbAssetId = thumbId;
}
} catch {
// optional
}
}
}
}
}
const audioRefs: Scene['media']['audios'] = [];
for (const audioPath of collectSceneAudioPaths(doc, playlistsById)) {
const assetId = await resolveAndImport(ctx, manifest, audioPath);
if (!assetId) continue;
if (ctx.assets[assetId]?.type !== 'audio') continue;
audioRefs.push({ assetId, autoplay: true, loop: true });
}
scenes[sceneId] = {
id: sceneId,
title: doc.name.trim() || 'Scene',
description: journalDescriptionHtml(doc, journalsById),
previewAssetId,
previewAssetType,
previewThumbAssetId,
previewVideoAutostart: previewAssetType === 'video',
previewRotationDeg: 0,
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: audioRefs },
settings: {
autoplayVideo: previewAssetType === 'video',
autoplayAudio: true,
loopVideo: true,
loopAudio: true,
},
connections: [],
layout: { x: 0, y: 0 },
};
sceneListOrder.push(sceneId);
}
const usedPlaylistIds = new Set(
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
);
const campaignAudios: Project['campaignAudios'] = [];
for (const pl of docs.playlists) {
if (usedPlaylistIds.has(pl._id)) continue;
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
for (const s of sounds) {
if (typeof s.path !== 'string' || !s.path.trim()) continue;
const assetId = await resolveAndImport(ctx, manifest, s.path);
if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue;
campaignAudios.push({ assetId, autoplay: false, loop: true });
}
}
const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders);
const npcs: ProjectNpc[] = [];
const usedNpcNames = new Set<string>();
let npcIndex = 0;
for (const actor of docs.actors) {
// Группы актёров Foundry (party/group) — не персонажи.
if (actor.type === 'group') continue;
step += 1;
onProgress?.({
stage: 'copy',
percent: 30 + Math.round((step / totalSteps) * 50),
detail: `НПС: ${actor.name}`,
});
const portrait = actorPortraitSrc(actor);
let avatarAssetId = portrait ? await resolveAndImport(ctx, manifest, portrait) : null;
if (!avatarAssetId || ctx.assets[avatarAssetId]?.type !== 'image') {
avatarAssetId = await importPlaceholderAvatar(ctx, actor.name);
}
const npcName = uniqueNpcName(actor.name, usedNpcNames);
const folderId = typeof actor.folder === 'string' ? actor.folder : null;
const groupId = folderId ? (foundryFolderToGroupId.get(folderId) ?? null) : null;
npcs.push({
id: asNpcId(`npc_${randomId()}`),
name: npcName,
avatarAssetId,
description: extractActorDescriptionHtml(actor),
x: 80 + (npcIndex % 4) * 220,
y: 80 + Math.floor(npcIndex / 4) * 200,
groupId,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
});
npcIndex += 1;
}
const sceneGraphNodes: SceneGraphNode[] = [];
const sceneGraphEdges: SceneGraphEdge[] = [];
const foundryToGraphNode = new Map<string, GraphNodeId>();
const startFoundryId = graphPlan.startSceneId;
const startSceneId =
(startFoundryId ? foundryToSceneId.get(startFoundryId) : null) ?? sceneListOrder[0] ?? null;
sceneListOrder.forEach((sceneId, index) => {
const foundryId = [...foundryToSceneId.entries()].find(([, sid]) => sid === sceneId)?.[0];
const col = index % 4;
const row = Math.floor(index / 4);
const gnId = asGraphNodeId(`gn_${randomId()}`);
sceneGraphNodes.push({
id: gnId,
sceneId,
x: 80 + col * 280,
y: 80 + row * 200,
isStartScene: startSceneId !== null && sceneId === startSceneId,
isSideStoryStart: false,
sideStoryLineTitle: '',
});
if (foundryId) foundryToGraphNode.set(foundryId, gnId);
});
for (const edge of graphPlan.edges) {
const source = foundryToGraphNode.get(edge.sourceId);
const target = foundryToGraphNode.get(edge.targetId);
if (!source || !target || source === target) continue;
sceneGraphEdges.push({
id: `e_${randomId()}`,
sourceGraphNodeId: source,
targetGraphNodeId: target,
});
}
// connections из рёбер графа
const outgoing = new Map<SceneId, Set<SceneId>>();
const gnMap = new Map(sceneGraphNodes.map((n) => [n.id, n]));
for (const e of sceneGraphEdges) {
const a = gnMap.get(e.sourceGraphNodeId);
const b = gnMap.get(e.targetGraphNodeId);
if (!a || !b || a.sceneId === b.sceneId) continue;
let set = outgoing.get(a.sceneId);
if (!set) {
set = new Set();
outgoing.set(a.sceneId, set);
}
set.add(b.sceneId);
}
for (const [sid, set] of outgoing) {
const sc = scenes[sid];
if (sc) sc.connections = [...set];
}
const startGraphNodeId = sceneGraphNodes.find((n) => n.isStartScene)?.id ?? null;
const project: Project = {
id: projectId,
meta: {
name,
fileBaseName,
createdAt: now,
updatedAt: now,
createdWithAppVersion: appVer,
appVersion: appVer,
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes,
sceneListOrder,
assets: ctx.assets,
campaignAudios,
materials: [],
npcs,
npcGroups,
npcRelations: [],
currentSceneId: startSceneId,
currentGraphNodeId: startGraphNodeId,
sceneGraphNodes,
sceneGraphEdges,
};
return { project, previewSources };
}
/** Actor folders Foundry → дерево групп НПС. */
function buildNpcGroupsFromFoundryFolders(folders: FoundryFolderDoc[]): {
npcGroups: ProjectNpcGroup[];
foundryFolderToGroupId: Map<string, NpcGroupId>;
} {
const actorFolders = folders
.filter((f) => !f.type || f.type === 'Actor')
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.name.localeCompare(b.name));
const foundryFolderToGroupId = new Map<string, NpcGroupId>();
for (const f of actorFolders) {
foundryFolderToGroupId.set(f._id, asNpcGroupId(`ng_${randomId()}`));
}
const nameKeysByParent = new Map<string | null, Set<string>>();
const npcGroups: ProjectNpcGroup[] = [];
// Parents first
const remaining = [...actorFolders];
const placed = new Set<string>();
while (remaining.length > 0) {
let progress = false;
for (let i = 0; i < remaining.length; i += 1) {
const f = remaining[i]!;
const parentFoundry = typeof f.folder === 'string' && f.folder.trim() ? f.folder.trim() : null;
if (parentFoundry && !foundryFolderToGroupId.has(parentFoundry)) {
// orphan parent ref → root
} else if (parentFoundry && !placed.has(parentFoundry) && foundryFolderToGroupId.has(parentFoundry)) {
continue;
}
const parentId = parentFoundry ? (foundryFolderToGroupId.get(parentFoundry) ?? null) : null;
const parentKey = parentId;
let keys = nameKeysByParent.get(parentKey);
if (!keys) {
keys = new Set();
nameKeysByParent.set(parentKey, keys);
}
let name = f.name.trim() || 'Group';
const base = name.toLowerCase();
if (keys.has(base)) {
let n = 2;
while (keys.has(`${base} (${String(n)})`)) n += 1;
name = `${name} (${String(n)})`;
}
keys.add(name.toLowerCase());
const id = foundryFolderToGroupId.get(f._id)!;
npcGroups.push({
id,
name,
color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR),
parentId,
});
placed.add(f._id);
remaining.splice(i, 1);
progress = true;
break;
}
if (!progress) {
// cycle / leftover → force as roots
for (const f of remaining) {
const id = foundryFolderToGroupId.get(f._id)!;
let name = f.name.trim() || 'Group';
const keys = nameKeysByParent.get(null) ?? new Set();
nameKeysByParent.set(null, keys);
if (keys.has(name.toLowerCase())) {
let n = 2;
while (keys.has(`${name.toLowerCase()} (${String(n)})`)) n += 1;
name = `${name} (${String(n)})`;
}
keys.add(name.toLowerCase());
npcGroups.push({
id,
name,
color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR),
parentId: null,
});
}
break;
}
}
return { npcGroups, foundryFolderToGroupId };
}
export async function loadFoundryDocumentsForImport(
sourcePath: string,
onProgress?: (p: FoundryImportProgress) => void,
): Promise<{
manifest: FoundryPackageManifest;
docs: FoundryLoadedDocuments;
cleanup: () => Promise<void>;
}> {
const prepared = await prepareSourceDir(sourcePath, onProgress);
try {
onProgress?.({ stage: 'copy', percent: 32, detail: 'Определение пакета Foundry…' });
const manifest = await detectFoundryPackage(prepared.workDir);
onProgress?.({
stage: 'copy',
percent: 36,
detail: manifest.kind === 'world' ? 'Чтение мира…' : 'Чтение модуля…',
});
const docs =
manifest.kind === 'world'
? await loadWorldDocuments(manifest.rootDir)
: await loadModuleDocuments(manifest.rootDir, manifest.packs);
if (docs.scenes.length === 0 && docs.actors.length === 0) {
throw new Error(
'В пакете Foundry не найдено сцен и актёров. Проверьте, что выбран мир/модуль с данными (не пустой шаблон).',
);
}
return { manifest, docs, cleanup: prepared.cleanup };
} catch (e) {
await prepared.cleanup();
throw e;
}
}
+1057 -61
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -18,6 +18,10 @@ function channelRequiresLicense(channel: string): boolean {
if (channel.startsWith('license.')) return false;
if (channel.startsWith('app.')) return false;
if (channel === ipcChannels.windows.closeMultiWindow) return false;
if (channel === ipcChannels.windows.closeSceneDescription) return false;
if (channel === ipcChannels.windows.closeMaterials) return false;
if (channel === ipcChannels.windows.closeNpcs) return false;
if (channel === ipcChannels.windows.closeNpcsEditor) return false;
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
if (channel === ipcChannels.project.list) return false;
+22 -8
View File
@@ -1,18 +1,32 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import { resolveMachineFingerprint } from './machineFingerprint';
import { deviceIdPath } from './paths';
export function getOrCreateDeviceId(userData: string): string {
const p = deviceIdPath(userData);
/** Старый per-user UUID из userData/device.id (до привязки к железу). */
export function readLegacyDeviceId(userData: string): string | null {
try {
const existing = fs.readFileSync(p, 'utf8').trim();
const existing = fs.readFileSync(deviceIdPath(userData), 'utf8').trim();
if (existing.length >= 8) return existing;
} catch {
/* empty */
}
const id = randomUUID();
fs.mkdirSync(userData, { recursive: true });
fs.writeFileSync(p, `${id}\n`, 'utf8');
return id;
return null;
}
export function clearLegacyDeviceId(userData: string): void {
try {
fs.unlinkSync(deviceIdPath(userData));
} catch {
/* empty */
}
}
/**
* Идентификатор устройства для лицензии: отпечаток физической машины.
* Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux).
* `userData` — путь для дискового кэша fingerprint (без повторного reg/wmic).
*/
export function getOrCreateDeviceId(userData?: string): string {
return resolveMachineFingerprint(userData ? { userData } : {});
}
+122 -20
View File
@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import { BrowserWindow, safeStorage } from 'electron';
@@ -6,11 +7,11 @@ import { ipcChannels } from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
import { isDndProductKey } from '../../shared/license/productKey';
import { isProductKey } from '../../shared/license/productKey';
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
import { getOrCreateDeviceId } from './deviceId';
import { licenseEncryptedPath, preferencesPath } from './paths';
import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
import { verifyLicenseToken } from './verifyLicenseToken';
type Preferences = {
@@ -19,6 +20,8 @@ type Preferences = {
type LicenseChangeListener = () => void;
const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii');
const licenseChangeListeners = new Set<LicenseChangeListener>();
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
@@ -63,35 +66,121 @@ function emitLicenseStatusChanged(): void {
export class LicenseService {
private readonly userData: string;
private readonly deviceId: string;
/** UUID из старого userData/device.id — только для совместимости до повторной активации. */
private legacyDeviceId: string | null;
private lastRemoteRevokeCheckMs = 0;
private lastRemoteRevoked = false;
constructor(userData: string) {
this.userData = userData;
this.deviceId = getOrCreateDeviceId(userData);
const legacy = readLegacyDeviceId(userData);
this.legacyDeviceId = legacy && legacy !== this.deviceId ? legacy : null;
}
private verifyOpts(nowSec: number): {
nowSec: number;
deviceId: string;
alsoAcceptDeviceIds?: readonly string[];
} {
return {
nowSec,
deviceId: this.deviceId,
...(this.legacyDeviceId ? { alsoAcceptDeviceIds: [this.legacyDeviceId] } : {}),
};
}
private isSkipLicense(): boolean {
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
}
private readSealedToken(): string | null {
const p = licenseEncryptedPath(this.userData);
if (!fs.existsSync(p)) return null;
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе');
/** Только для окружений без OS keychain (WSL и т.п.); слабее safeStorage — см. licensing-spec. */
private isInsecureFileStorageAllowed(): boolean {
const v = process.env.DND_LICENSE_INSECURE_FILE_STORAGE?.trim().toLowerCase();
return v === '1' || v === 'true' || v === 'yes';
}
private deriveFallbackKey(): Buffer {
return crypto
.createHash('sha256')
.update('TTRPGPlayer.license.fallback.v1\0', 'utf8')
.update(this.deviceId, 'utf8')
.digest();
}
private readFallbackSealedToken(): string {
const p = licenseFallbackSealedPath(this.userData);
const buf = fs.readFileSync(p);
if (buf.length < FALLBACK_MAGIC.length + 12 + 16 + 1) {
throw new Error('license.fallback: файл повреждён или слишком короткий');
}
if (!buf.subarray(0, FALLBACK_MAGIC.length).equals(FALLBACK_MAGIC)) {
throw new Error('license.fallback: неверный формат');
}
const iv = buf.subarray(FALLBACK_MAGIC.length, FALLBACK_MAGIC.length + 12);
const tag = buf.subarray(FALLBACK_MAGIC.length + 12, FALLBACK_MAGIC.length + 12 + 16);
const data = buf.subarray(FALLBACK_MAGIC.length + 12 + 16);
const key = this.deriveFallbackKey();
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
}
private writeFallbackSealedToken(token: string): void {
const key = this.deriveFallbackKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
const payload = Buffer.concat([FALLBACK_MAGIC, iv, tag, enc]);
fs.writeFileSync(licenseFallbackSealedPath(this.userData), payload, { mode: 0o600 });
}
private readSealedToken(): string | null {
const sealedPath = licenseEncryptedPath(this.userData);
const fallbackPath = licenseFallbackSealedPath(this.userData);
if (fs.existsSync(sealedPath)) {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error(
'safeStorage недоступен: есть license.sealed, но расшифровать нельзя (часто перенос профиля или WSL без keyring). Удалите файл лицензии в настройках приложения или используйте DND_LICENSE_INSECURE_FILE_STORAGE=1 и активируйте заново.',
);
}
const buf = fs.readFileSync(sealedPath);
return safeStorage.decryptString(buf);
}
private writeSealedToken(token: string): void {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе');
if (fs.existsSync(fallbackPath)) {
return this.readFallbackSealedToken();
}
return null;
}
private writeSealedToken(token: string): void {
fs.mkdirSync(this.userData, { recursive: true });
if (safeStorage.isEncryptionAvailable()) {
const enc = safeStorage.encryptString(token);
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
try {
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
} catch {
/* ok */
}
return;
}
if (this.isInsecureFileStorageAllowed()) {
this.writeFallbackSealedToken(token);
try {
fs.unlinkSync(licenseEncryptedPath(this.userData));
} catch {
/* ok */
}
return;
}
throw new Error(
'safeStorage недоступен: нельзя сохранить лицензию на этой системе (типично WSL без gnome-keyring). Запустите с переменной DND_LICENSE_INSECURE_FILE_STORAGE=1 — токен будет сохранён в зашифрованном файле (слабее OS-хранилища); либо настройте Secret Service / gnome-keyring.',
);
}
private clearSealedTokenFile(): void {
@@ -100,22 +189,34 @@ export class LicenseService {
} catch {
/* ok */
}
try {
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
} catch {
/* ok */
}
}
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
private resolveLicenseActivateBaseUrl(): string {
const raw = process.env.DND_LICENSE_STATUS_URL?.trim();
if (raw) return raw.endsWith('/') ? raw : `${raw}/`;
return 'https://license.mailib.ru/';
return 'https://license.ttrpgplayer.ru/';
}
private async activateWithProductKey(productKey: string): Promise<string> {
const base = this.resolveLicenseActivateBaseUrl();
const url = new URL('v1/activate', base);
const body: { productKey: string; deviceId: string; retireDeviceId?: string } = {
productKey: productKey.trim(),
deviceId: this.deviceId,
};
if (this.legacyDeviceId) {
body.retireDeviceId = this.legacyDeviceId;
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ productKey: productKey.trim(), deviceId: this.deviceId }),
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
const text = await res.text();
@@ -133,6 +234,10 @@ export class LicenseService {
if (!token || typeof token !== 'string') {
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
}
if (this.legacyDeviceId) {
clearLegacyDeviceId(this.userData);
this.legacyDeviceId = null;
}
return normalizeLicenseTokenInput(token);
}
@@ -200,7 +305,7 @@ export class LicenseService {
};
}
const v = verifyLicenseToken(token, { nowSec, deviceId: this.deviceId });
const v = verifyLicenseToken(token, this.verifyOpts(nowSec));
if (!v.ok) {
return {
active: false,
@@ -250,10 +355,7 @@ export class LicenseService {
if (!base.active || !base.summary) return base;
const token = this.readSealedToken();
if (!token?.trim()) return base;
const v = verifyLicenseToken(token, {
nowSec: Math.floor(Date.now() / 1000),
deviceId: this.deviceId,
});
const v = verifyLicenseToken(token, this.verifyOpts(Math.floor(Date.now() / 1000)));
if (!v.ok) return this.getStatusSync();
void this.maybeRefreshRemoteRevocation(v.payload);
return this.getStatusSync();
@@ -264,11 +366,11 @@ export class LicenseService {
return this.getStatusSync();
}
let trimmed = normalizeLicenseTokenInput(token);
if (isDndProductKey(trimmed)) {
if (isProductKey(trimmed)) {
trimmed = await this.activateWithProductKey(trimmed);
}
const nowSec = Math.floor(Date.now() / 1000);
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
const v = verifyLicenseToken(trimmed, this.verifyOpts(nowSec));
if (!v.ok) {
throw new Error(`LICENSE_INVALID:${v.reason}`);
}
+151
View File
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
clearMachineFingerprintMemoryCache,
hashMachineRawId,
machineWideIdPath,
parseMacIOPlatformUUID,
parseWindowsMachineGuid,
parseWmicUuid,
resolveMachineFingerprint,
} from './machineFingerprint';
import { machineFingerprintCachePath } from './paths';
void test('hashMachineRawId: стабилен и не зависит от регистра GUID', () => {
const a = hashMachineRawId('win32', 'ABCDEF00-1111-2222-3333-444455556666');
const b = hashMachineRawId('win32', 'abcdef00-1111-2222-3333-444455556666');
assert.equal(a, b);
assert.equal(a.length, 64);
assert.notEqual(a, hashMachineRawId('linux', 'abcdef00-1111-2222-3333-444455556666'));
});
void test('parseWindowsMachineGuid', () => {
const out = `
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
`;
assert.equal(parseWindowsMachineGuid(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
assert.equal(parseWindowsMachineGuid('nope'), null);
});
void test('parseMacIOPlatformUUID', () => {
const out = `
+-o IOPlatformExpertDevice <class IOPlatformExpertDevice, id 0x1000001ea, registered, matched>
{
"IOPlatformUUID" = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
}
`;
assert.equal(parseMacIOPlatformUUID(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
});
void test('parseWmicUuid', () => {
assert.equal(parseWmicUuid('UUID\nA1B2C3D4-E5F6-7890-ABCD-EF1234567890\n'), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
assert.equal(parseWmicUuid('UUID\n00000000-0000-0000-0000-000000000000\n'), null);
});
void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID', () => {
clearMachineFingerprintMemoryCache();
const id = resolveMachineFingerprint({
platform: 'linux',
env: { DND_LICENSE_DEVICE_ID: 'override-device-id-12345' },
});
assert.equal(id, 'override-device-id-12345');
});
void test('resolveMachineFingerprint: Windows MachineGuid → одинаковый hash', () => {
clearMachineFingerprintMemoryCache();
const exec = () =>
`
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
`;
const a = resolveMachineFingerprint({
platform: 'win32',
env: {},
execFileSync: exec as never,
});
clearMachineFingerprintMemoryCache();
const b = resolveMachineFingerprint({
platform: 'win32',
env: {},
execFileSync: exec as never,
});
assert.equal(a, b);
assert.equal(a, hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890'));
});
void test('resolveMachineFingerprint: Linux /etc/machine-id', () => {
clearMachineFingerprintMemoryCache();
const id = resolveMachineFingerprint({
platform: 'linux',
env: {},
readFileSync: (p) => {
if (p === '/etc/machine-id') return '0123456789abcdef0123456789abcdef\n';
throw new Error('enoent');
},
});
assert.equal(id, hashMachineRawId('linux', '0123456789abcdef0123456789abcdef'));
});
void test('resolveMachineFingerprint: fallback в machine-wide путь', () => {
clearMachineFingerprintMemoryCache();
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-'));
const env = { PROGRAMDATA: tmp };
const p = machineWideIdPath('win32', env);
const id1 = resolveMachineFingerprint({
platform: 'win32',
env,
execFileSync: () => {
throw new Error('no reg');
},
});
clearMachineFingerprintMemoryCache();
const id2 = resolveMachineFingerprint({
platform: 'win32',
env,
execFileSync: () => {
throw new Error('no reg');
},
});
assert.equal(id1, id2);
assert.ok(fs.existsSync(p));
fs.rmSync(tmp, { recursive: true, force: true });
});
void test('resolveMachineFingerprint: disk cache — без повторного exec', () => {
clearMachineFingerprintMemoryCache();
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-cache-'));
const expected = hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
let execCalls = 0;
const exec = () => {
execCalls += 1;
return `
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
`;
};
const a = resolveMachineFingerprint({
platform: 'win32',
env: {},
userData: tmp,
execFileSync: exec as never,
});
assert.equal(a, expected);
assert.equal(execCalls, 1);
assert.ok(fs.existsSync(machineFingerprintCachePath(tmp)));
clearMachineFingerprintMemoryCache();
const b = resolveMachineFingerprint({
platform: 'win32',
env: {},
userData: tmp,
execFileSync: exec as never,
});
assert.equal(b, expected);
assert.equal(execCalls, 1, 'второй вызов читает disk cache, без reg/wmic');
fs.rmSync(tmp, { recursive: true, force: true });
});
+260
View File
@@ -0,0 +1,260 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { machineFingerprintCachePath } from './paths';
type ExecFile = (
file: string,
args: readonly string[],
options: { encoding: 'utf8'; windowsHide?: boolean; timeout?: number },
) => string;
export type MachineFingerprintDeps = {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
/** Electron userData — для дискового кэша hashed fingerprint. */
userData?: string;
execFileSync?: ExecFile;
readFileSync?: (p: string, encoding: 'utf8') => string;
existsSync?: (p: string) => boolean;
mkdirSync?: (p: string, opts: { recursive: boolean }) => void;
writeFileSync?: (p: string, data: string, opts?: { mode?: number }) => void;
};
/** Process-level кэш: повторные вызовы в том же процессе без I/O. */
let memoryFingerprint: string | null = null;
/** Только для тестов. */
export function clearMachineFingerprintMemoryCache(): void {
memoryFingerprint = null;
}
function isPlausiblyFingerprint(id: string): boolean {
return id.length >= 8 && id.length <= 128 && !/\s/.test(id);
}
const HASH_PREFIX = 'TTRPGPlayer.machine.v1\0';
/** Стабильный opaque id из сырого машинного идентификатора ОС. */
export function hashMachineRawId(platform: string, rawId: string): string {
return createHash('sha256')
.update(HASH_PREFIX, 'utf8')
.update(platform, 'utf8')
.update('\0', 'utf8')
.update(rawId.trim().toLowerCase(), 'utf8')
.digest('hex');
}
export function parseWindowsMachineGuid(regOutput: string): string | null {
const m = /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{8,})/.exec(regOutput);
const id = m?.[1]?.trim();
return id && id.length >= 8 ? id : null;
}
export function parseMacIOPlatformUUID(ioregOutput: string): string | null {
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(ioregOutput);
const id = m?.[1]?.trim();
return id && id.length >= 8 ? id : null;
}
export function parseWmicUuid(wmicOutput: string): string | null {
const lines = wmicOutput
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
for (const line of lines) {
if (/^uuid$/i.test(line)) continue;
if (/^[0-9a-fA-F-]{8,}$/.test(line) && !/^0+-?0+-?0+-?0+-?0+$/.test(line)) {
return line;
}
}
return null;
}
function tryExec(exec: ExecFile, file: string, args: readonly string[]): string | null {
try {
return exec(file, args, { encoding: 'utf8', windowsHide: true, timeout: 8_000 });
} catch {
return null;
}
}
function readWindowsRawId(exec: ExecFile): string | null {
const regOut = tryExec(exec, 'reg', [
'query',
'HKLM\\SOFTWARE\\Microsoft\\Cryptography',
'/v',
'MachineGuid',
]);
if (regOut) {
const guid = parseWindowsMachineGuid(regOut);
if (guid) return guid;
}
const wmicOut = tryExec(exec, 'wmic', ['csproduct', 'get', 'uuid']);
if (wmicOut) {
const uuid = parseWmicUuid(wmicOut);
if (uuid) return uuid;
}
return null;
}
function readDarwinRawId(exec: ExecFile): string | null {
const out = tryExec(exec, 'ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
if (!out) return null;
return parseMacIOPlatformUUID(out);
}
function readLinuxRawId(readFile: (p: string, encoding: 'utf8') => string): string | null {
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
try {
const id = readFile(p, 'utf8').trim();
if (id.length >= 8 && !/^0+$/.test(id)) return id;
} catch {
/* try next */
}
}
try {
const id = readFile('/sys/class/dmi/id/product_uuid', 'utf8').trim();
if (id.length >= 8 && !/^0+-?0+-?0+-?0+-?0+$/i.test(id)) return id;
} catch {
/* optional, often root-only */
}
return null;
}
/** Каталог на уровне машины (не профиль пользователя), для редкого fallback UUID. */
export function machineWideIdPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {
if (platform === 'win32') {
const base = env.PROGRAMDATA?.trim() || path.join(env.SystemDrive || 'C:', 'ProgramData');
return path.join(base, 'TTRPGPlayer', 'machine.id');
}
if (platform === 'darwin') {
return path.join('/Library/Application Support', 'TTRPGPlayer', 'machine.id');
}
return path.join('/var/lib', 'ttrpg-player', 'machine.id');
}
function readOrCreateMachineWideFallback(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
deps: Required<
Pick<MachineFingerprintDeps, 'existsSync' | 'readFileSync' | 'mkdirSync' | 'writeFileSync'>
>,
): string | null {
const p = machineWideIdPath(platform, env);
try {
if (deps.existsSync(p)) {
const existing = deps.readFileSync(p, 'utf8').trim();
if (existing.length >= 8) return existing;
}
} catch {
/* create below */
}
const id = createHash('sha256')
.update(`TTRPGPlayer.machine.fallback\0${platform}\0${os.hostname()}\0${Date.now()}\0${Math.random()}`, 'utf8')
.digest('hex');
try {
deps.mkdirSync(path.dirname(p), { recursive: true });
deps.writeFileSync(p, `${id}\n`, { mode: 0o644 });
return id;
} catch {
return null;
}
}
function readDiskFingerprintCache(
cacheFile: string,
readFile: (p: string, encoding: 'utf8') => string,
existsSync: (p: string) => boolean,
): string | null {
try {
if (!existsSync(cacheFile)) return null;
const cached = readFile(cacheFile, 'utf8').trim();
return isPlausiblyFingerprint(cached) ? cached : null;
} catch {
return null;
}
}
function writeDiskFingerprintCache(
cacheFile: string,
fingerprint: string,
mkdirSync: (p: string, opts: { recursive: boolean }) => void,
writeFileSync: (p: string, data: string, opts?: { mode?: number }) => void,
): void {
try {
mkdirSync(path.dirname(cacheFile), { recursive: true });
writeFileSync(cacheFile, `${fingerprint}\n`, { mode: 0o644 });
} catch {
/* кэш необязателен */
}
}
/**
* Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК).
* Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id.
* Кэш: память процесса → userData/machine.fingerprint → sync probe ОС только при miss.
*/
export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): string {
const platform = deps.platform ?? process.platform;
const env = deps.env ?? process.env;
const exec = deps.execFileSync ?? (execFileSync as ExecFile);
const readFile = deps.readFileSync ?? ((p, enc) => fs.readFileSync(p, enc));
const existsSync = deps.existsSync ?? ((p) => fs.existsSync(p));
const mkdirSync = deps.mkdirSync ?? ((p, opts) => {
fs.mkdirSync(p, opts);
});
const writeFileSync = deps.writeFileSync ?? ((p, data, opts) => {
fs.writeFileSync(p, data, opts);
});
const override = env.DND_LICENSE_DEVICE_ID?.trim();
if (override && override.length >= 8) {
memoryFingerprint = override;
return override;
}
if (memoryFingerprint && isPlausiblyFingerprint(memoryFingerprint)) {
return memoryFingerprint;
}
const userData = deps.userData?.trim();
const cacheFile = userData ? machineFingerprintCachePath(userData) : null;
if (cacheFile) {
const fromDisk = readDiskFingerprintCache(cacheFile, readFile, existsSync);
if (fromDisk) {
memoryFingerprint = fromDisk;
return fromDisk;
}
}
let raw: string | null = null;
if (platform === 'win32') raw = readWindowsRawId(exec);
else if (platform === 'darwin') raw = readDarwinRawId(exec);
else if (platform === 'linux') raw = readLinuxRawId(readFile);
if (!raw) {
raw = readOrCreateMachineWideFallback(platform, env, {
existsSync,
readFileSync: readFile,
mkdirSync,
writeFileSync,
});
}
if (!raw) {
throw new Error(
'LICENSE_MACHINE_ID_UNAVAILABLE: не удалось получить идентификатор физической машины',
);
}
const fingerprint = hashMachineRawId(platform, raw);
memoryFingerprint = fingerprint;
if (cacheFile) {
writeDiskFingerprintCache(cacheFile, fingerprint, mkdirSync, writeFileSync);
}
return fingerprint;
}
+11
View File
@@ -4,10 +4,21 @@ export function licenseEncryptedPath(userData: string): string {
return path.join(userData, 'license.sealed');
}
/** Fallback, если нет OS keychain (WSL без gnome-keyring и т.п.); только при DND_LICENSE_INSECURE_FILE_STORAGE=1. */
export function licenseFallbackSealedPath(userData: string): string {
return path.join(userData, 'license.sealed.fallback');
}
/** Устаревший per-user UUID; актуальный deviceId — fingerprint машины (см. machineFingerprint.ts). */
export function deviceIdPath(userData: string): string {
return path.join(userData, 'device.id');
}
/** Кэш hashed machine fingerprint — без повторного reg/wmic/ioreg на каждом старте. */
export function machineFingerprintCachePath(userData: string): string {
return path.join(userData, 'machine.fingerprint');
}
export function preferencesPath(userData: string): string {
return path.join(userData, 'preferences.json');
}
@@ -55,6 +55,30 @@ void test('verifyLicenseToken: неверное устройство', () => {
assert.equal(bad.reason, 'wrong_device');
});
void test('verifyLicenseToken: принимает legacy deviceId при миграции', () => {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
const payload = {
v: 1 as const,
sub: 'lic_legacy',
pid: 'dnd_player',
iat: 100,
exp: 2_000_000_000,
did: 'legacy-uuid-from-userdata',
};
const body = canonicalJson(payload);
const sig = sign(null, Buffer.from(body, 'utf8'), privateKey);
const token = joinSignedLicenseToken(body, new Uint8Array(sig.buffer, sig.byteOffset, sig.byteLength));
const ok = verifyLicenseToken(token, {
nowSec: 1_700_000_000,
deviceId: 'machine-fingerprint-hash',
alsoAcceptDeviceIds: ['legacy-uuid-from-userdata'],
publicKeyOverrideSpkiDerB64: pubB64,
});
if (!ok.ok) assert.fail(`expected ok, got ${ok.reason}`);
});
void test('verifyLicenseToken: токен с переносами строк после копирования', () => {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
+11 -2
View File
@@ -23,7 +23,13 @@ function getBundledPublicKey() {
export function verifyLicenseToken(
token: string,
opts: { nowSec: number; deviceId: string; publicKeyOverrideSpkiDerB64?: string },
opts: {
nowSec: number;
deviceId: string;
/** Старые deviceId (например UUID из userData) — принимаются до повторной активации. */
alsoAcceptDeviceIds?: readonly string[];
publicKeyOverrideSpkiDerB64?: string;
},
): LicenseVerifyResult {
const parts = splitSignedLicenseToken(token);
if (!parts) return { ok: false, reason: 'malformed' };
@@ -53,9 +59,12 @@ export function verifyLicenseToken(
return { ok: false, reason: 'not_yet_valid' };
}
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
if (payload.did !== null && payload.did !== opts.deviceId) {
if (payload.did !== null) {
const accepted = new Set<string>([opts.deviceId, ...(opts.alsoAcceptDeviceIds ?? [])]);
if (!accepted.has(payload.did)) {
return { ok: false, reason: 'wrong_device' };
}
}
return { ok: true, payload };
}
+228
View File
@@ -0,0 +1,228 @@
import {
clampMaterialsLayout,
DEFAULT_MATERIALS_OVERLAY_LAYOUT,
defaultLegendLayoutForMaterial,
type MaterialId,
type MaterialsOverlayEvent,
type MaterialsOverlayLayout,
type MaterialsOverlayState,
type MaterialsZoomTool,
zoomMaterialsLayoutAt,
} from '../../shared/types';
function emptyState(): MaterialsOverlayState {
return {
revision: 1,
activeMaterialIds: [],
layouts: {},
legendLayouts: {},
focusMaterialId: null,
zoomTool: null,
};
}
function initialLayout(rotationDeg?: number): MaterialsOverlayLayout {
return clampMaterialsLayout({
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
rotationDeg: rotationDeg ?? 0,
});
}
function layoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
return state.layouts[materialId] ?? { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT };
}
function legendLayoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
return state.legendLayouts[materialId] ?? defaultLegendLayoutForMaterial();
}
export class MaterialsOverlayStore {
private state: MaterialsOverlayState = emptyState();
getState(): MaterialsOverlayState {
return this.state;
}
clear(): MaterialsOverlayState {
if (this.state.activeMaterialIds.length === 0 && this.state.zoomTool === null) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeMaterialIds: [],
layouts: {},
legendLayouts: {},
focusMaterialId: null,
zoomTool: null,
};
return this.state;
}
dispatch(event: MaterialsOverlayEvent): MaterialsOverlayState {
switch (event.kind) {
case 'hide':
return this.clear();
case 'show': {
if (this.state.activeMaterialIds.includes(event.materialId)) {
this.state = {
...this.state,
revision: this.state.revision + 1,
focusMaterialId: event.materialId,
};
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
layouts: {
...this.state.layouts,
[event.materialId]: initialLayout(event.rotationDeg),
},
legendLayouts: {
...this.state.legendLayouts,
[event.materialId]: defaultLegendLayoutForMaterial(),
},
focusMaterialId: event.materialId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'toggle': {
if (this.state.activeMaterialIds.includes(event.materialId)) {
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => id !== event.materialId);
const layouts = { ...this.state.layouts };
const legendLayouts = { ...this.state.legendLayouts };
delete layouts[event.materialId];
delete legendLayouts[event.materialId];
const focusMaterialId =
this.state.focusMaterialId === event.materialId
? (activeMaterialIds[activeMaterialIds.length - 1] ?? null)
: this.state.focusMaterialId;
this.state = {
revision: this.state.revision + 1,
activeMaterialIds,
layouts,
legendLayouts,
focusMaterialId,
zoomTool: activeMaterialIds.length === 0 ? null : this.state.zoomTool,
};
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
layouts: {
...this.state.layouts,
[event.materialId]: initialLayout(event.rotationDeg),
},
legendLayouts: {
...this.state.legendLayouts,
[event.materialId]: defaultLegendLayoutForMaterial(),
},
focusMaterialId: event.materialId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'layout.set': {
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
focusMaterialId: event.materialId,
layouts: {
...this.state.layouts,
[event.materialId]: clampMaterialsLayout({
...layoutFor(this.state, event.materialId),
...event.layout,
}),
},
};
return this.state;
}
case 'legendLayout.set': {
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
focusMaterialId: event.materialId,
legendLayouts: {
...this.state.legendLayouts,
[event.materialId]: clampMaterialsLayout({
...legendLayoutFor(this.state, event.materialId),
...event.layout,
rotationDeg: 0,
}),
},
};
return this.state;
}
case 'zoomTool.set': {
const tool: MaterialsZoomTool = event.tool;
this.state = {
...this.state,
revision: this.state.revision + 1,
zoomTool: tool,
};
return this.state;
}
case 'zoomAt': {
if (this.state.activeMaterialIds.length === 0 || !this.state.zoomTool) return this.state;
const targetId =
(event.materialId && this.state.activeMaterialIds.includes(event.materialId)
? event.materialId
: null) ??
this.state.focusMaterialId ??
this.state.activeMaterialIds[this.state.activeMaterialIds.length - 1] ??
null;
if (!targetId) return this.state;
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt(
layoutFor(this.state, targetId),
event.nx,
event.ny,
factor,
);
this.state = {
...this.state,
revision: this.state.revision + 1,
focusMaterialId: targetId,
layouts: {
...this.state.layouts,
[targetId]: layout,
},
};
return this.state;
}
default:
return this.state;
}
}
ensureMaterialStillExists(materialIds: ReadonlySet<MaterialId>): MaterialsOverlayState {
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => materialIds.has(id));
if (activeMaterialIds.length === this.state.activeMaterialIds.length) return this.state;
if (activeMaterialIds.length === 0) return this.clear();
const layouts: Record<string, MaterialsOverlayLayout> = {};
const legendLayouts: Record<string, MaterialsOverlayLayout> = {};
for (const id of activeMaterialIds) {
const layout = this.state.layouts[id];
if (layout) layouts[id] = layout;
const legend = this.state.legendLayouts[id];
if (legend) legendLayouts[id] = legend;
}
const focusMaterialId =
this.state.focusMaterialId && activeMaterialIds.includes(this.state.focusMaterialId)
? this.state.focusMaterialId
: (activeMaterialIds[activeMaterialIds.length - 1] ?? null);
this.state = {
revision: this.state.revision + 1,
activeMaterialIds,
layouts,
legendLayouts,
focusMaterialId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
}
+180
View File
@@ -0,0 +1,180 @@
import {
clampNpcsLayout,
DEFAULT_NPCS_OVERLAY_LAYOUT,
type NpcId,
type NpcsOverlayEvent,
type NpcsOverlayLayout,
type NpcsOverlayState,
type NpcsZoomTool,
zoomNpcsLayoutAt,
} from '../../shared/types';
function emptyState(): NpcsOverlayState {
return {
revision: 1,
activeNpcIds: [],
layouts: {},
focusNpcId: null,
zoomTool: null,
};
}
function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
}
export class NpcsOverlayStore {
private state: NpcsOverlayState = emptyState();
getState(): NpcsOverlayState {
return this.state;
}
clear(): NpcsOverlayState {
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeNpcIds: [],
layouts: {},
focusNpcId: null,
zoomTool: null,
};
return this.state;
}
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
switch (event.kind) {
case 'hide':
return this.clear();
case 'show': {
if (this.state.activeNpcIds.includes(event.npcId)) {
this.state = {
...this.state,
revision: this.state.revision + 1,
focusNpcId: event.npcId,
};
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
...this.state.layouts,
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'toggle': {
if (this.state.activeNpcIds.includes(event.npcId)) {
const activeNpcIds = this.state.activeNpcIds.filter((id) => id !== event.npcId);
const layouts = { ...this.state.layouts };
delete layouts[event.npcId];
const focusNpcId =
this.state.focusNpcId === event.npcId
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
: this.state.focusNpcId;
this.state = {
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
};
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
...this.state.layouts,
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'layout.set': {
if (!this.state.activeNpcIds.includes(event.npcId)) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
focusNpcId: event.npcId,
layouts: {
...this.state.layouts,
[event.npcId]: clampNpcsLayout({
...layoutFor(this.state, event.npcId),
...event.layout,
}),
},
};
return this.state;
}
case 'zoomTool.set': {
const tool: NpcsZoomTool = event.tool;
this.state = {
...this.state,
revision: this.state.revision + 1,
zoomTool: tool,
};
return this.state;
}
case 'zoomAt': {
if (this.state.activeNpcIds.length === 0 || !this.state.zoomTool) return this.state;
const targetId =
(event.npcId && this.state.activeNpcIds.includes(event.npcId) ? event.npcId : null) ??
this.state.focusNpcId ??
this.state.activeNpcIds[this.state.activeNpcIds.length - 1] ??
null;
if (!targetId) return this.state;
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
const layout: NpcsOverlayLayout = zoomNpcsLayoutAt(
layoutFor(this.state, targetId),
event.nx,
event.ny,
factor,
);
this.state = {
...this.state,
revision: this.state.revision + 1,
focusNpcId: targetId,
layouts: {
...this.state.layouts,
[targetId]: layout,
},
};
return this.state;
}
default:
return this.state;
}
}
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
if (activeNpcIds.length === this.state.activeNpcIds.length) return this.state;
if (activeNpcIds.length === 0) return this.clear();
const layouts: Record<string, NpcsOverlayLayout> = {};
for (const id of activeNpcIds) {
const layout = this.state.layouts[id];
if (layout) layouts[id] = layout;
}
const focusNpcId =
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
? this.state.focusNpcId
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
this.state = {
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
}
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { PlayersStore } from './playersStore';
import { asPlayerId } from '../../shared/types/ids';
async function withTempStore(run: (store: PlayersStore, root: string) => Promise<void>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-players-'));
const store = new PlayersStore(root);
try {
await run(store, root);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
void test('PlayersStore upsert list delete and teams', async () => {
await withTempStore(async (store, root) => {
const png = path.join(root, 'sample.png');
// minimal 1x1 png
const buf = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
await fs.writeFile(png, buf);
const progress: number[] = [];
const player = await store.upsert(
{ name: 'Ada', filePath: png, ringColor: '#112233', imageOffset: { x: 0.1, y: -0.1 } },
(p) => progress.push(p.percent),
);
assert.equal(player.name, 'Ada');
assert.equal(player.ringColor, '#112233');
assert.ok(progress.length > 0);
assert.equal(store.listPlayers().length, 1);
const team = await store.upsertTeam({ name: 'Party', color: '#abcdef' });
assert.equal(team.name, 'Party');
const assigned = await store.assignPlayerTeam(player.id, team.id);
assert.equal(assigned?.teamId, team.id);
await store.deleteTeam(team.id);
assert.equal(store.listTeams().length, 0);
assert.equal(store.getById(player.id)?.teamId, null);
await store.delete(player.id);
assert.equal(store.listPlayers().length, 0);
});
});
void test('PlayersStore persists across reload', async () => {
await withTempStore(async (store, root) => {
const png = path.join(root, 'sample.png');
await fs.writeFile(
png,
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
),
);
const created = await store.upsert({ name: 'Bob', filePath: png });
const store2 = new PlayersStore(root);
await store2.ensureLoaded();
assert.equal(store2.listPlayers().length, 1);
assert.equal(store2.getById(asPlayerId(created.id))?.name, 'Bob');
});
});
+295
View File
@@ -0,0 +1,295 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
assignPlayerToTeam,
createPlayerTeamDraft,
deletePlayerTeam,
normalizeAppPlayerTeams,
uniquePlayerTeamName,
} from '../../shared/players/playerTeams';
import type {
AppPlayer,
AppPlayerTeam,
PlayerId,
PlayerImageOffset,
PlayersUpsertProgressEvent,
PlayerTeamId,
} from '../../shared/types/appPlayers';
import {
clampPlayerImageOffset,
clampPlayerImageScale,
DEFAULT_PLAYER_IMAGE_OFFSET,
DEFAULT_PLAYER_IMAGE_SCALE,
DEFAULT_PLAYER_RING_COLOR,
normalizeAppPlayer,
} from '../../shared/types/appPlayers';
import { asPlayerId } from '../../shared/types/ids';
import { normalizeHexColor } from '../../shared/npcs/npcGroups';
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
type PlayersManifest = {
players: AppPlayer[];
teams: AppPlayerTeam[];
};
function mimeFromExt(ext: string): string {
const e = ext.toLowerCase();
if (e === '.png') return 'image/png';
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
if (e === '.webp') return 'image/webp';
if (e === '.gif') return 'image/gif';
return 'application/octet-stream';
}
function safeFileBase(name: string): string {
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
return base || 'player';
}
function randomPlayerId(): PlayerId {
return asPlayerId(`player_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
export class PlayersStore {
private readonly rootDir: string;
private readonly filesDir: string;
private readonly manifestPath: string;
private players: AppPlayer[] = [];
private teams: AppPlayerTeam[] = [];
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'players');
this.filesDir = path.join(this.rootDir, 'files');
this.manifestPath = path.join(this.rootDir, 'players.json');
}
async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await fs.mkdir(this.filesDir, { recursive: true });
try {
const raw = await fs.readFile(this.manifestPath, 'utf8');
const parsed = JSON.parse(raw) as PlayersManifest;
this.teams = normalizeAppPlayerTeams(parsed.teams);
const teamIds = new Set(this.teams.map((t) => t.id));
this.players = (Array.isArray(parsed.players) ? parsed.players : [])
.map((p) => normalizeAppPlayer(p, teamIds))
.filter((p): p is AppPlayer => Boolean(p));
} catch {
this.players = [];
this.teams = [];
await this.persist();
}
this.loaded = true;
}
listPlayers(): AppPlayer[] {
return [...this.players];
}
listTeams(): AppPlayerTeam[] {
return [...this.teams];
}
getById(id: PlayerId): AppPlayer | null {
return this.players.find((p) => p.id === id) ?? null;
}
getImageReadInfo(id: PlayerId): { absPath: string; mime: string } | null {
const player = this.getById(id);
if (!player) return null;
const absPath = path.join(this.rootDir, player.imageRelPath);
return { absPath, mime: mimeFromExt(path.extname(player.imageRelPath)) };
}
getImageUrl(id: PlayerId): string | null {
if (!this.getImageReadInfo(id)) return null;
return `dnd://player?id=${encodeURIComponent(id)}`;
}
async upsert(
input: {
id?: PlayerId | null;
name: string;
filePath?: string | null;
teamId?: PlayerTeamId | null;
ringColor?: string;
imageOffset?: PlayerImageOffset;
imageScale?: number;
},
onProgress?: (p: PlayersUpsertProgressEvent) => void,
): Promise<AppPlayer> {
await this.ensureLoaded();
const name = input.name.trim();
if (!name) throw new Error('Player name is required');
const existing = input.id ? this.getById(input.id) : null;
if (input.id && !existing) throw new Error('Player not found');
if (!existing && !input.filePath) throw new Error('Player image is required');
const emit = (percent: number, stage: string, detail?: string) => {
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
};
emit(5, 'prepare', 'Подготовка…');
let imageRelPath = existing?.imageRelPath ?? '';
let sha256 = existing?.sha256 ?? '';
const id = existing?.id ?? randomPlayerId();
if (input.filePath) {
emit(15, 'read', 'Чтение изображения…');
let buf = await fs.readFile(input.filePath);
emit(40, 'optimize', 'Оптимизация…');
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
/* keep original */
}
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const ext = path.extname(input.filePath) || '.png';
const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`;
imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
const abs = path.join(this.rootDir, imageRelPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
emit(75, 'write', 'Сохранение…');
await fs.writeFile(abs, buf);
if (existing && existing.imageRelPath !== imageRelPath) {
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
}
const teamIds = new Set(this.teams.map((t) => t.id));
let teamId: PlayerTeamId | null =
input.teamId !== undefined ? input.teamId : (existing?.teamId ?? null);
if (teamId && !teamIds.has(teamId)) teamId = null;
const player: AppPlayer = {
id,
name,
imageRelPath,
sha256,
teamId,
ringColor: normalizeHexColor(
input.ringColor ?? existing?.ringColor,
DEFAULT_PLAYER_RING_COLOR,
),
imageOffset: clampPlayerImageOffset(
input.imageOffset ?? existing?.imageOffset ?? DEFAULT_PLAYER_IMAGE_OFFSET,
),
imageScale: clampPlayerImageScale(
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE,
),
};
if (existing) {
this.players = this.players.map((p) => (p.id === player.id ? player : p));
} else {
this.players = [...this.players, player];
}
emit(95, 'persist', 'Запись…');
await this.persist();
emit(100, 'done', 'Готово');
return player;
}
async delete(id: PlayerId): Promise<void> {
await this.ensureLoaded();
const existing = this.getById(id);
if (!existing) return;
this.players = this.players.filter((p) => p.id !== id);
await this.persist();
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
async setPlayersOrder(playerIds: PlayerId[]): Promise<AppPlayer[]> {
await this.ensureLoaded();
const byId = new Map(this.players.map((p) => [p.id, p]));
const next: AppPlayer[] = [];
for (const id of playerIds) {
const p = byId.get(id);
if (p) {
next.push(p);
byId.delete(id);
}
}
for (const p of byId.values()) next.push(p);
this.players = next;
await this.persist();
return this.listPlayers();
}
async upsertTeam(input: {
id?: PlayerTeamId | null;
name: string;
color?: string;
}): Promise<AppPlayerTeam> {
await this.ensureLoaded();
const existing = input.id ? this.teams.find((t) => t.id === input.id) : null;
if (input.id && !existing) throw new Error('Team not found');
if (existing) {
const team: AppPlayerTeam = {
id: existing.id,
name: uniquePlayerTeamName(input.name, this.teams, existing.id),
color: normalizeHexColor(input.color ?? existing.color, DEFAULT_PLAYER_RING_COLOR),
};
this.teams = this.teams.map((t) => (t.id === team.id ? team : t));
await this.persist();
return team;
}
const team = createPlayerTeamDraft(input.name, input.color, this.teams);
this.teams = [...this.teams, team];
await this.persist();
return team;
}
async deleteTeam(id: PlayerTeamId): Promise<void> {
await this.ensureLoaded();
const next = deletePlayerTeam(this.teams, this.players, id);
this.teams = next.teams;
this.players = next.players;
await this.persist();
}
async setTeamsOrder(teamIds: PlayerTeamId[]): Promise<AppPlayerTeam[]> {
await this.ensureLoaded();
const byId = new Map(this.teams.map((t) => [t.id, t]));
const next: AppPlayerTeam[] = [];
for (const id of teamIds) {
const t = byId.get(id);
if (t) {
next.push(t);
byId.delete(id);
}
}
for (const t of byId.values()) next.push(t);
this.teams = next;
await this.persist();
return this.listTeams();
}
async assignPlayerTeam(playerId: PlayerId, teamId: PlayerTeamId | null): Promise<AppPlayer | null> {
await this.ensureLoaded();
const teamIds = new Set(this.teams.map((t) => t.id as string));
this.players = assignPlayerToTeam(this.players, playerId, teamId, teamIds);
await this.persist();
return this.getById(playerId);
}
private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
const payload: PlayersManifest = { players: this.players, teams: this.teams };
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
}
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
import { SceneNpcTokensSessionStore } from './sceneNpcTokensSessionStore';
void test('SceneNpcTokensSessionStore move and reset', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
assert.equal(s1.byPlacementId.a?.nx, 0.2);
assert.equal(s1.revision, 2);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
assert.equal(s2.revision, s1.revision); // no-op
const s3 = store.reset();
assert.deepEqual(s3.byPlacementId, {});
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
assert.ok(s3.revision > s1.revision);
});
void test('SceneNpcTokensSessionStore setScale', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setScale', scale: 1.5 });
assert.equal(s1.scale, 1.5);
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.1, ny: 0.2 });
const s2 = store.dispatch({ kind: 'setScale', scale: 0.1 });
assert.equal(s2.scale, 0.4); // clamped min
assert.ok(s2.byPlacementId.a);
const s3 = store.reset();
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
assert.deepEqual(s3.byPlacementId, {});
});
void test('SceneNpcTokensSessionStore setDisposition without inventing position', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({
kind: 'setDisposition',
placementId: 'a',
disposition: 'hostile',
});
assert.equal(s1.byPlacementId.a?.disposition, 'hostile');
assert.equal(s1.byPlacementId.a?.nx, undefined);
assert.equal(s1.byPlacementId.a?.ny, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.4, ny: 0.6 });
assert.equal(s2.byPlacementId.a?.nx, 0.4);
assert.equal(s2.byPlacementId.a?.disposition, 'hostile');
});
void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
assert.equal(s1.byPlacementId.a?.inactive, true);
const s2 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
assert.deepEqual(s2.byPlacementId, {});
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
const s3 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
assert.equal(s3.byPlacementId.a?.nx, 0.2);
assert.equal(s3.byPlacementId.a?.inactive, undefined);
});
@@ -0,0 +1,153 @@
import {
clampNpcTokenSessionScale,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
type SceneNpcTokensSessionEvent,
type SceneNpcTokensSessionPlacement,
type SceneNpcTokensSessionState,
} from '../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
function emptyState(revision = 1): SceneNpcTokensSessionState {
return {
revision,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
}
function withMove(
prev: SceneNpcTokensSessionPlacement | undefined,
nx: number,
ny: number,
): SceneNpcTokensSessionPlacement {
return {
nx,
ny,
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}),
};
}
function withoutPlacement(
state: SceneNpcTokensSessionState,
placementId: string,
): SceneNpcTokensSessionState {
if (!(placementId in state.byPlacementId)) return state;
const { [placementId]: _removed, ...rest } = state.byPlacementId;
return {
revision: state.revision + 1,
byPlacementId: rest,
scale: state.scale,
};
}
export class SceneNpcTokensSessionStore {
private state: SceneNpcTokensSessionState = emptyState();
getState(): SceneNpcTokensSessionState {
return this.state;
}
reset(): SceneNpcTokensSessionState {
if (
Object.keys(this.state.byPlacementId).length === 0 &&
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
) {
return this.state;
}
this.state = emptyState(this.state.revision + 1);
return this.state;
}
dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'setScale': {
const scale = clampNpcTokenSessionScale(event.scale);
if (this.state.scale === scale) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
scale,
};
return this.state;
}
case 'move': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlacementId[placementId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: withMove(prev, nx, ny),
},
scale: this.state.scale,
};
return this.state;
}
case 'setDisposition': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const disposition = normalizeNpcDisposition(event.disposition);
const prev = this.state.byPlacementId[placementId];
if (prev?.disposition === disposition) return this.state;
const next: SceneNpcTokensSessionPlacement = {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
disposition,
...(prev?.inactive ? { inactive: true } : {}),
};
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
};
return this.state;
}
case 'setInactive': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const inactive = Boolean(event.inactive);
const prev = this.state.byPlacementId[placementId];
if (Boolean(prev?.inactive) === inactive) return this.state;
const next: SceneNpcTokensSessionPlacement = {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(inactive ? { inactive: true } : {}),
};
if (isEmptyPlacement(next)) {
this.state = withoutPlacement(this.state, placementId);
return this.state;
}
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ScenePlayerTokensSessionStore } from './scenePlayerTokensSessionStore';
void test('ScenePlayerTokensSessionStore selection and show/hide', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['a', 'b', 'a'] });
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
assert.equal(store.getState().visible, false);
store.dispatch({ kind: 'show', sizeN: 0.08 });
assert.equal(store.getState().visible, true);
assert.equal(Object.keys(store.getState().byPlayerId).length, 2);
assert.ok(store.getState().byPlayerId.a);
assert.equal(store.getState().byPlayerId.a?.ny, 0.88);
store.dispatch({ kind: 'setVisible', visible: false });
assert.equal(store.getState().visible, false);
assert.ok(store.getState().byPlayerId.a);
store.dispatch({ kind: 'clearPlacements' });
assert.equal(store.getState().visible, false);
assert.deepEqual(store.getState().byPlayerId, {});
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
});
void test('ScenePlayerTokensSessionStore move and reset', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
store.dispatch({ kind: 'show', sizeN: 0.1 });
const s1 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.3, ny: 0.4 });
assert.equal(s1.byPlayerId.p1?.nx, 0.3);
assert.equal(s1.byPlayerId.p1?.ny, 0.4);
const s2 = store.reset();
assert.deepEqual(s2.selectedPlayerIds, []);
assert.equal(s2.visible, false);
assert.deepEqual(s2.byPlayerId, {});
});
@@ -0,0 +1,143 @@
import {
clampSceneNpcTokenSizeN,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
layoutPlayerTokensBottom,
type ScenePlayerTokensSessionEvent,
type ScenePlayerTokensSessionState,
} from '../../shared/types/appPlayers';
function emptyState(revision = 1): ScenePlayerTokensSessionState {
return {
revision,
selectedPlayerIds: [],
visible: false,
byPlayerId: {},
};
}
function normalizePlayerIds(raw: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const id of raw) {
const s = String(id ?? '').trim();
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
export class ScenePlayerTokensSessionStore {
private state: ScenePlayerTokensSessionState = emptyState();
getState(): ScenePlayerTokensSessionState {
return this.state;
}
reset(): ScenePlayerTokensSessionState {
if (
this.state.selectedPlayerIds.length === 0 &&
!this.state.visible &&
Object.keys(this.state.byPlayerId).length === 0
) {
return this.state;
}
this.state = emptyState(this.state.revision + 1);
return this.state;
}
/** Смена сцены: спрятать токены, сбросить позиции; выбор игроков сохранить. */
clearPlacements(): ScenePlayerTokensSessionState {
if (!this.state.visible && Object.keys(this.state.byPlayerId).length === 0) {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
visible: false,
byPlayerId: {},
};
return this.state;
}
dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'clearPlacements':
return this.clearPlacements();
case 'setSelection': {
const selectedPlayerIds = normalizePlayerIds(event.playerIds);
const same =
selectedPlayerIds.length === this.state.selectedPlayerIds.length &&
selectedPlayerIds.every((id, i) => id === this.state.selectedPlayerIds[i]);
if (same) return this.state;
this.state = {
revision: this.state.revision + 1,
selectedPlayerIds,
visible: false,
byPlayerId: {},
};
return this.state;
}
case 'setVisible': {
const visible = Boolean(event.visible);
if (this.state.visible === visible) return this.state;
if (visible && this.state.selectedPlayerIds.length === 0) return this.state;
this.state = { ...this.state, revision: this.state.revision + 1, visible };
return this.state;
}
case 'show': {
if (this.state.selectedPlayerIds.length === 0) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
const byPlayerId =
Object.keys(this.state.byPlayerId).length > 0
? this.state.byPlayerId
: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN);
if (this.state.visible && byPlayerId === this.state.byPlayerId) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
visible: true,
byPlayerId,
};
return this.state;
}
case 'seedBottom': {
if (this.state.selectedPlayerIds.length === 0) return this.state;
if (Object.keys(this.state.byPlayerId).length > 0) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN),
};
return this.state;
}
case 'move': {
const playerId = String(event.playerId ?? '');
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlayerId[playerId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
const sizeN = clampSceneNpcTokenSizeN(prev?.sizeN ?? DEFAULT_SCENE_NPC_TOKEN_SIZE_N);
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: {
...this.state.byPlayerId,
[playerId]: { nx, ny, sizeN },
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
+6 -2
View File
@@ -22,9 +22,11 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
},
},
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
} as unknown as Project;
const s = collectReferencedAssetIds(p);
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'th', 'v1'].sort());
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'mat1', 'npc1', 'pr', 'th', 'v1'].sort());
});
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
@@ -65,12 +67,14 @@ void test('reconcileAssetFiles: снимает осиротевшие assets и
...base,
scenes: {},
campaignAudios: [],
materials: [],
assets: { orphan: asset } as Project['assets'],
};
const next: Project = {
...base,
scenes: {},
campaignAudios: [],
materials: [],
assets: { orphan: asset } as Project['assets'],
};
@@ -115,7 +119,7 @@ void test('reconcileAssetFiles: удаляет файл при исключен
} as unknown as Project;
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
const next: Project = { ...base, campaignAudios: [], assets: {} as Project['assets'] };
const next: Project = { ...base, campaignAudios: [], materials: [], assets: {} as Project['assets'] };
const out = await reconcileAssetFiles(prev, next, tmp);
assert.deepEqual(out.assets, {});
+2
View File
@@ -14,6 +14,8 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
for (const au of sc.media.audios) refs.add(au.assetId);
}
for (const au of p.campaignAudios) refs.add(au.assetId);
for (const m of p.materials ?? []) refs.add(m.assetId);
for (const n of p.npcs ?? []) refs.add(n.avatarAssetId);
return refs;
}
+17 -5
View File
@@ -2,10 +2,16 @@ import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
PROJECT_ZIP_EXTENSION,
PROJECT_ZIP_EXTENSION_LEGACY,
isProjectZipFileName,
} from '../../shared/project/projectZipExtension';
import { readProjectJsonFromZip } from './yauzlProjectZip';
/**
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.dnd.zip.tmp`).
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp`).
* Нельзя сначала удалять `finalPath`: при сбое rename после rm проект теряется (Windows/антивирус).
*/
export async function replaceFileAtomic(completedSrc: string, finalPath: string): Promise<void> {
@@ -50,8 +56,10 @@ export async function replaceFileAtomic(completedSrc: string, finalPath: string)
await fs.unlink(backupPath).catch(() => undefined);
}
/** Если сохранение оборвалось, остаётся только `*.dnd.zip.tmp` — восстанавливаем в `*.dnd.zip`. */
export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void> {
const ZIP_TMP_SUFFIXES = [`${PROJECT_ZIP_EXTENSION}.tmp`, `${PROJECT_ZIP_EXTENSION_LEGACY}.tmp`] as const;
/** Если сохранение оборвалось, остаётся `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp` — восстанавливаем финальный архив. */
export async function recoverOrphanProjectZipTmpInRoot(root: string): Promise<void> {
let names: string[];
try {
names = await fs.readdir(root);
@@ -59,10 +67,11 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
return;
}
for (const name of names) {
if (!name.endsWith('.dnd.zip.tmp')) continue;
const matchedSuffix = ZIP_TMP_SUFFIXES.find((s) => name.endsWith(s));
if (!matchedSuffix) continue;
const tmpPath = path.join(root, name);
const finalName = name.slice(0, -'.tmp'.length);
if (!finalName.endsWith('.dnd.zip')) continue;
if (!isProjectZipFileName(finalName)) continue;
const finalPath = path.join(root, finalName);
try {
await fs.access(finalPath);
@@ -80,3 +89,6 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
}
}
}
/** @deprecated Используйте {@link recoverOrphanProjectZipTmpInRoot}. */
export const recoverOrphanDndZipTmpInRoot = recoverOrphanProjectZipTmpInRoot;
+8 -1
View File
@@ -17,7 +17,14 @@ export function getProjectsCacheRootDir(): string {
export function getLegacyProjectsRootDirs(): string[] {
const cur = getProjectsRootDir();
const parent = path.dirname(app.getPath('userData'));
const siblingNames = ['DnD Player', 'dnd-player', 'DNDGamePlayer', 'dnd_player'];
const siblingNames = [
'TTRPG Player',
'TTRPGPlayer',
'DnD Player',
'dnd-player',
'DNDGamePlayer',
'dnd_player',
];
const out: string[] = [];
for (const n of siblingNames) {
const p = path.join(parent, n, 'projects');
@@ -33,3 +33,19 @@ void test('generateScenePreviewThumbnailBytes: image scales to max edge', async
await fs.rm(tmp, { recursive: true, force: true });
});
void test('generateScenePreviewThumbnailBytes: accepts image Buffer', async () => {
const png = await sharp({
create: {
width: 120,
height: 80,
channels: 3,
background: { r: 10, g: 20, b: 30 },
},
})
.png()
.toBuffer();
const buf = await generateScenePreviewThumbnailBytes(png, 'image');
assert.ok(buf !== null);
assert.ok(buf.length > 0);
});
+6 -2
View File
@@ -14,14 +14,15 @@ export const SCENE_PREVIEW_THUMB_MAX_PX = 320;
/**
* Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds).
* For images, prefer a Buffer so callers are not racy with reconcileAssetFiles unlinking paths.
*/
export async function generateScenePreviewThumbnailBytes(
sourceAbsPath: string,
source: string | Buffer,
kind: 'image' | 'video',
): Promise<Buffer | null> {
try {
if (kind === 'image') {
return await sharp(sourceAbsPath)
return await sharp(source)
.rotate()
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
fit: 'inside',
@@ -31,6 +32,9 @@ export async function generateScenePreviewThumbnailBytes(
.toBuffer();
}
const sourceAbsPath = typeof source === 'string' ? source : null;
if (!sourceAbsPath) return null;
const ffmpegPath = ffmpegStatic;
if (!ffmpegPath) return null;
+2 -2
View File
@@ -13,7 +13,7 @@ import { readProjectJsonFromZip } from './yauzlProjectZip';
void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-read-'));
const zipPath = path.join(tmp, 'test.dnd.zip');
const zipPath = path.join(tmp, 'test.ttrpg.zip');
const minimal = {
id: 'p1',
meta: {
@@ -54,7 +54,7 @@ void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', as
void test('readProjectJsonFromZip: campaignAudios round-trips inside project.json', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-campaign-'));
const zipPath = path.join(tmp, 'test.dnd.zip');
const zipPath = path.join(tmp, 'test.ttrpg.zip');
const assetId = 'audio_asset_1';
const minimal = {
id: 'p1',
@@ -28,9 +28,43 @@ void test('zipStore: openProjectById flushes pending saveNow before cache reset'
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
// When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first.
assert.match(src, /async openProjectById/);
assert.match(src, /enqueueOpenProject/);
assert.match(src, /openProjectByIdInner/);
assert.match(src, /if \(this\.openProject\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
assert.match(src, /await this\.drainSavePipeline\(\)/);
assert.match(src, /await fs\.rm\(cacheDir, \{ recursive: true, force: true \}\)/);
assert.match(src, /await unzipToDir\(zipPath, cacheDir\)/);
assert.match(src, /await unzipToDir\(zipPath, cacheDir/);
});
void test('zipStore: openProjectById skips re-unzip when project is already open', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*return this\.openProject\.project;\s*\}/);
});
void test('zipStore: pack and open operations are serialized', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /private packChain: Promise<void>/);
assert.match(src, /private projectSwitchChain: Promise<void>/);
assert.match(src, /enqueuePack/);
assert.match(src, /enqueueOpenProject/);
assert.match(src, /enqueueProjectSwitch/);
});
void test('zipStore: project updates and preview finalize are serialized', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /private projectUpdateChain: Promise<unknown>/);
assert.match(src, /private previewFinalizeChain: Promise<unknown>/);
assert.match(src, /enqueueProjectUpdate/);
assert.match(src, /enqueuePreviewFinalize/);
assert.match(src, /finalizeScenePreviewImportInner/);
assert.match(src, /drainProjectMutations/);
assert.match(src, /addBuffer/);
});
void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/);
assert.match(src, /Открытие проекта отменено/);
});
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
@@ -58,7 +92,7 @@ void test('atomicReplace: replaceFileAtomic must not rm destination before succe
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
const i = src.indexOf('export async function replaceFileAtomic');
assert.ok(i >= 0);
const j = src.indexOf('export async function recoverOrphanDndZipTmpInRoot', i);
const j = src.indexOf('export async function recoverOrphanProjectZipTmpInRoot', i);
assert.ok(j > i);
const block = src.slice(i, j);
assert.match(block, /rename\(finalPath, backupPath\)/);
+1635 -135
View File
File diff suppressed because it is too large Load Diff
+59 -27
View File
@@ -2,26 +2,14 @@ import fs from 'node:fs/promises';
import { session } from 'electron';
import { asAssetId } from '../../shared/types/ids';
import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids';
import type { PlayersStore } from '../players/playersStore';
import type { ZipProjectStore } from '../project/zipStore';
import type { TokensStore } from '../tokens/tokensStore';
/**
* Обслуживает `dnd://asset?...` — без этого `<img src="file://...">` в рендерере часто ломается.
*/
export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url);
if (url.hostname !== 'asset') {
return new Response(null, { status: 404 });
}
const id = url.searchParams.get('id');
if (!id) {
return new Response(null, { status: 404 });
}
const info = projectStore.getAssetReadInfo(asAssetId(id));
if (!info) {
return new Response(null, { status: 404 });
}
type ReadInfo = { absPath: string; mime: string };
async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
try {
const stat = await fs.stat(info.absPath);
const total = stat.size;
@@ -33,22 +21,39 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
const start = Number(m[1]);
const endRaw = m[2] ? Number(m[2]) : total - 1;
const end = Math.min(endRaw, total - 1);
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
return new Response(null, { status: 416 });
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
return new Response(null, {
status: 416,
headers: {
'Content-Range': `bytes */${String(total)}`,
'Cache-Control': 'no-store',
},
});
}
const len = end - start + 1;
const fh = await fs.open(info.absPath, 'r');
try {
const buf = Buffer.alloc(len);
await fh.read(buf, 0, len, start);
return new Response(buf, {
const { bytesRead } = await fh.read(buf, 0, len, start);
if (bytesRead <= 0) {
return new Response(null, {
status: 416,
headers: {
'Content-Range': `bytes */${String(total)}`,
'Cache-Control': 'no-store',
},
});
}
const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead));
const actualEnd = start + bytesRead - 1;
return new Response(body, {
status: 206,
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`,
'Content-Length': String(len),
'Cache-Control': 'public, max-age=300',
'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
'Content-Length': String(body.length),
'Cache-Control': 'no-store',
},
});
} finally {
@@ -62,12 +67,39 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Length': String(buf.length),
'Cache-Control': 'public, max-age=300',
'Cache-Control': 'no-store',
},
});
} catch {
return new Response(null, { status: 404 });
}
}
/**
* Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`.
*/
export function registerDndAssetProtocol(
projectStore: ZipProjectStore,
tokensStore: TokensStore,
playersStore: PlayersStore | null,
): void {
session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) {
return new Response(null, { status: 404 });
}
let info: ReadInfo | null = null;
if (url.hostname === 'asset') {
info = projectStore.getAssetReadInfo(asAssetId(id));
} else if (url.hostname === 'token') {
info = tokensStore.getImageReadInfo(asTokenId(id));
} else if (url.hostname === 'player') {
info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null;
}
if (!info) {
return new Response(null, { status: 404 });
}
return serveFile(info, request);
});
}
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
void test('main: EPIPE guards ставятся до app.whenReady', () => {
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
const safe = fs.readFileSync(path.join(here, 'safeConsole.ts'), 'utf8');
assert.ok(index.includes('installStdoutEpipeGuards'));
assert.ok(index.indexOf('installStdoutEpipeGuards()') < index.indexOf('app.requestSingleInstanceLock'));
assert.ok(safe.includes('EPIPE'));
assert.ok(safe.includes('safeConsoleError'));
});
+26
View File
@@ -0,0 +1,26 @@
/**
* В Electron (особенно после рестарта в dev) stdout/stderr часто уже закрыты.
* Обычный `console.error` тогда даёт EPIPE и валит main process диалогом Uncaught Exception.
*/
function isBrokenPipe(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED';
}
export function installStdoutEpipeGuards(): void {
for (const stream of [process.stdout, process.stderr]) {
stream?.on('error', (err: NodeJS.ErrnoException) => {
if (isBrokenPipe(err)) return;
});
}
}
export function safeConsoleError(...args: unknown[]): void {
try {
console.error(...args);
} catch (err) {
if (isBrokenPipe(err)) return;
throw err;
}
}
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneTrapsStore } from './sceneTrapsStore';
void test('SceneTrapsStore: сохраняет runtime при переключении сцен внутри сессии', () => {
const store = new SceneTrapsStore();
store.switchScene('scene_a', ['t1']);
store.dispatch({ kind: 'activate', trapId: 't1' });
assert.equal(store.getState().byId.t1?.status, 'active');
assert.equal(store.getState().byId.t1?.revealed, true);
store.switchScene('scene_b', ['t2']);
assert.equal(store.getState().byId.t2?.status, 'inactive');
store.switchScene('scene_a', ['t1']);
assert.equal(store.getState().byId.t1?.status, 'active');
assert.equal(store.getState().byId.t1?.revealed, true);
});
void test('SceneTrapsStore: resetSession сбрасывает кэш к дефолту (кнопка «Запустить»)', () => {
const store = new SceneTrapsStore();
store.switchScene('scene_a', ['t1', 't2']);
store.dispatch({ kind: 'reveal', trapId: 't1' });
store.dispatch({ kind: 'activate', trapId: 't2' });
assert.equal(store.getState().byId.t1?.revealed, true);
assert.equal(store.getState().byId.t2?.status, 'active');
assert.ok(store.getState().lastActivation);
store.resetSession();
store.switchScene('scene_a', ['t1', 't2']);
assert.equal(store.getState().byId.t1?.status, 'inactive');
assert.equal(store.getState().byId.t1?.revealed, false);
assert.equal(store.getState().byId.t2?.status, 'inactive');
assert.equal(store.getState().byId.t2?.revealed, false);
assert.equal(store.getState().lastActivation, null);
});
+134
View File
@@ -0,0 +1,134 @@
import {
defaultTrapRuntime,
type SceneTrapsEvent,
type SceneTrapsState,
type SceneTrapRuntime,
} from '../../shared/types';
function emptyState(): SceneTrapsState {
return {
revision: 1,
cacheKey: null,
byId: {},
lastActivation: null,
};
}
function cloneById(byId: Record<string, SceneTrapRuntime>): Record<string, SceneTrapRuntime> {
const out: Record<string, SceneTrapRuntime> = {};
for (const [k, v] of Object.entries(byId)) {
out[k] = { ...v };
}
return out;
}
export class SceneTrapsStore {
private state: SceneTrapsState = emptyState();
/** Кэш runtime по ключу сцены/ноды на время сессии показа. */
private cache = new Map<string, Record<string, SceneTrapRuntime>>();
private currentKey: string | null = null;
private activationToken = 0;
getState(): SceneTrapsState {
return {
...this.state,
byId: cloneById(this.state.byId),
lastActivation: this.state.lastActivation ? { ...this.state.lastActivation } : null,
};
}
resetSession(): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
}
/**
* Переключение сцены: сохраняем runtime в кэш и подгружаем/инициализируем для новых trapIds.
*/
switchScene(cacheKey: string | null, trapIds: readonly string[]): SceneTrapsState {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, cloneById(this.state.byId));
}
this.currentKey = cacheKey;
const cached = cacheKey ? this.cache.get(cacheKey) : undefined;
const byId: Record<string, SceneTrapRuntime> = {};
for (const id of trapIds) {
byId[id] = cached?.[id] ? { ...cached[id]! } : defaultTrapRuntime();
}
if (cacheKey) {
this.cache.set(cacheKey, cloneById(byId));
}
this.state = {
revision: this.state.revision + 1,
cacheKey,
byId,
lastActivation: null,
};
return this.getState();
}
dispatch(event: SceneTrapsEvent): SceneTrapsState {
switch (event.kind) {
case 'syncTrapIds': {
const byId: Record<string, SceneTrapRuntime> = {};
for (const id of event.trapIds) {
byId[id] = this.state.byId[id] ? { ...this.state.byId[id]! } : defaultTrapRuntime();
}
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
this.state = {
...this.state,
revision: this.state.revision + 1,
byId,
};
return this.getState();
}
case 'reveal': {
const cur = this.state.byId[event.trapId];
if (!cur) return this.getState();
const byId = cloneById(this.state.byId);
byId[event.trapId] = { ...cur, revealed: true };
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
this.state = {
...this.state,
revision: this.state.revision + 1,
byId,
};
return this.getState();
}
case 'activate': {
const cur = this.state.byId[event.trapId];
if (!cur) return this.getState();
const byId = cloneById(this.state.byId);
byId[event.trapId] = { status: 'active', revealed: true };
this.activationToken += 1;
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
this.state = {
...this.state,
revision: this.state.revision + 1,
byId,
lastActivation: { trapId: event.trapId, token: this.activationToken },
};
return this.getState();
}
case 'disarm': {
const cur = this.state.byId[event.trapId];
if (!cur) return this.getState();
const byId = cloneById(this.state.byId);
byId[event.trapId] = { status: 'disarmed', revealed: true };
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
this.state = {
...this.state,
revision: this.state.revision + 1,
byId,
};
return this.getState();
}
default: {
const _x: never = event;
void _x;
return this.getState();
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
sceneViewZoomAt,
} from '../../shared/types/sceneView';
import { SceneViewStore } from './sceneViewStore';
describe('SceneViewStore', () => {
it('resets to default camera', () => {
const store = new SceneViewStore();
store.dispatch({ kind: 'set', camera: { scale: 2, ox: 0.2, oy: 0.8 } });
const next = store.dispatch({ kind: 'reset' });
assert.equal(next.scale, 1);
assert.equal(next.ox, 0.5);
assert.equal(next.oy, 0.5);
});
it('clamps scale and origin', () => {
const store = new SceneViewStore();
const next = store.dispatch({ kind: 'set', camera: { scale: 99, ox: -1, oy: 2 } });
assert.equal(next.scale, 8);
assert.equal(next.ox, 0);
assert.equal(next.oy, 1);
});
});
describe('sceneViewZoomAt / panBy', () => {
it('zooms toward cursor and keeps that content point under cursor', () => {
const hostW = 1000;
const hostH = 500;
const containW = 800;
const containH = 400;
const hostX = 700;
const hostY = 250;
const next = sceneViewZoomAt(DEFAULT_SCENE_VIEW_CAMERA, {
hostW,
hostH,
containW,
containH,
hostX,
hostY,
factor: 2,
});
assert.ok(next.scale > 1.5);
const displayW = containW * next.scale;
const displayH = containH * next.scale;
const left = hostW / 2 - next.ox * displayW;
const top = hostH / 2 - next.oy * displayH;
const ix = (hostX - left) / displayW;
const iy = (hostY - top) / displayH;
// At scale=1 contain is centered; cursor was at content x=(700-100)/800=0.75
assert.ok(Math.abs(ix - 0.75) < 1e-6);
assert.ok(Math.abs(iy - 0.5) < 1e-6);
});
it('pans in host pixels', () => {
const cam = { scale: 2, ox: 0.5, oy: 0.5 };
const next = sceneViewPanBy(cam, { containW: 400, containH: 200, dx: 80, dy: 0 });
// displayW=800; dx=80 → ox decreases by 0.1
assert.ok(Math.abs(next.ox - 0.4) < 1e-6);
assert.equal(next.oy, 0.5);
});
});
+64
View File
@@ -0,0 +1,64 @@
import {
clampSceneViewCamera,
DEFAULT_SCENE_VIEW_CAMERA,
type SceneViewCamera,
type SceneViewEvent,
type SceneViewState,
} from '../../shared/types';
function emptyState(): SceneViewState {
return {
revision: 1,
...DEFAULT_SCENE_VIEW_CAMERA,
};
}
export class SceneViewStore {
private state: SceneViewState = emptyState();
getState(): SceneViewState {
return this.state;
}
reset(): SceneViewState {
if (
this.state.scale === 1 &&
this.state.ox === 0.5 &&
this.state.oy === 0.5
) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
...DEFAULT_SCENE_VIEW_CAMERA,
};
return this.state;
}
dispatch(event: SceneViewEvent): SceneViewState {
switch (event.kind) {
case 'reset':
return this.reset();
case 'set': {
const camera: SceneViewCamera = clampSceneViewCamera(event.camera);
if (
camera.scale === this.state.scale &&
camera.ox === this.state.ox &&
camera.oy === this.state.oy
) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
...camera,
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
/**
* Фаза 6 (срез): layout-only мутации не должны слать полный Project во все окна.
* Редактор НПС применяет позицию из ответа invoke локально.
*/
void test('session IPC: нет emitSessionState на graph/NPC position hot-path', () => {
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
const createWindows = fs.readFileSync(path.join(here, 'windows/createWindows.ts'), 'utf8');
const npcsEditor = fs.readFileSync(
path.join(here, '../renderer/npcs/NpcsEditorApp.tsx'),
'utf8',
);
assert.ok(index.includes('sendToAppWindows(ipcChannels.session.stateChanged'));
assert.ok(createWindows.includes('SESSION_STATE_WINDOW_KINDS'));
assert.ok(createWindows.includes('sendToAppWindows'));
const kindsBlock = /SESSION_STATE_WINDOW_KINDS: readonly WindowKind\[\] = \[([\s\S]*?)\] as const/.exec(
createWindows,
);
assert.ok(kindsBlock, 'SESSION_STATE_WINDOW_KINDS объявлен');
assert.doesNotMatch(kindsBlock[1] ?? '', /'editor'/, 'editor не получает session.stateChanged');
assert.match(kindsBlock[1] ?? '', /'presentation'/);
assert.match(kindsBlock[1] ?? '', /'control'/);
// Handlers больше не вызывают emitSessionState сразу после layout-update.
const npcPosHandler =
/registerHandler\(ipcChannels\.project\.updateNpcPosition,\s*async\s*\(\{ npcId, x, y \}\) => \{([\s\S]*?)\}\);/.exec(
index,
);
const graphPosHandler =
/registerHandler\(ipcChannels\.project\.updateSceneGraphNodePosition,\s*async\s*\(\{ nodeId, x, y \}\) => \{([\s\S]*?)\}\);/.exec(
index,
);
assert.ok(npcPosHandler, 'updateNpcPosition handler');
assert.ok(graphPosHandler, 'updateSceneGraphNodePosition handler');
assert.doesNotMatch(npcPosHandler[1] ?? '', /emitSessionState/);
assert.doesNotMatch(graphPosHandler[1] ?? '', /emitSessionState/);
assert.match(npcPosHandler[1] ?? '', /return \{ project \}/);
assert.match(graphPosHandler[1] ?? '', /return \{ project \}/);
assert.ok(npcsEditor.includes('updateNpcPosition'));
assert.match(
npcsEditor,
/onNodePositionCommit[\s\S]*?setSession\([\s\S]*?npcs: prev\.project\.npcs\.map/,
);
assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/);
});
@@ -0,0 +1,54 @@
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
function emptyState(): SceneTokensSessionState {
return {
revision: 1,
byPlacementId: {},
};
}
export class SceneTokensSessionStore {
private state: SceneTokensSessionState = emptyState();
getState(): SceneTokensSessionState {
return this.state;
}
reset(): SceneTokensSessionState {
if (Object.keys(this.state.byPlacementId).length === 0) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {},
};
return this.state;
}
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'move': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlacementId[placementId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: { nx, ny },
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TokenGridSnapSessionStore } from './tokenGridSnapSessionStore';
void test('TokenGridSnapSessionStore: set and reset', () => {
const store = new TokenGridSnapSessionStore();
assert.equal(store.getState().enabled, false);
assert.equal(store.setEnabled(true).enabled, true);
assert.equal(store.getState().enabled, true);
assert.equal(store.reset().enabled, false);
});
@@ -0,0 +1,21 @@
export type TokenGridSnapSessionState = {
enabled: boolean;
};
export class TokenGridSnapSessionStore {
private enabled = false;
getState(): TokenGridSnapSessionState {
return { enabled: this.enabled };
}
setEnabled(enabled: boolean): TokenGridSnapSessionState {
this.enabled = Boolean(enabled);
return this.getState();
}
reset(): TokenGridSnapSessionState {
this.enabled = false;
return this.getState();
}
}
+272
View File
@@ -0,0 +1,272 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { AppToken, TokenId } from '../../shared/types';
import { asTokenId } from '../../shared/types/ids';
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
type TokensManifest = {
tokens: AppToken[];
};
function mimeFromExt(ext: string): string {
const e = ext.toLowerCase();
if (e === '.png') return 'image/png';
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
if (e === '.webp') return 'image/webp';
if (e === '.gif') return 'image/gif';
return 'application/octet-stream';
}
function safeFileBase(name: string): string {
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
return base || 'token';
}
function randomTokenId(): TokenId {
return asTokenId(`token_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
export class TokensStore {
private readonly rootDir: string;
private readonly filesDir: string;
private readonly manifestPath: string;
private tokens: AppToken[] = [];
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'tokens');
this.filesDir = path.join(this.rootDir, 'files');
this.manifestPath = path.join(this.rootDir, 'tokens.json');
}
async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await fs.mkdir(this.filesDir, { recursive: true });
try {
const raw = await fs.readFile(this.manifestPath, 'utf8');
const parsed = JSON.parse(raw) as TokensManifest;
this.tokens = Array.isArray(parsed.tokens)
? parsed.tokens.filter(
(t): t is AppToken =>
Boolean(t) &&
typeof t.id === 'string' &&
typeof t.name === 'string' &&
typeof t.imageRelPath === 'string' &&
typeof t.sha256 === 'string',
)
: [];
} catch {
this.tokens = [];
await this.persist();
}
this.loaded = true;
}
list(): AppToken[] {
return [...this.tokens];
}
getById(id: TokenId): AppToken | null {
return this.tokens.find((t) => t.id === id) ?? null;
}
findBySha256(sha256: string): AppToken | null {
return this.tokens.find((t) => t.sha256 === sha256) ?? null;
}
getImageReadInfo(id: TokenId): { absPath: string; mime: string } | null {
const token = this.getById(id);
if (!token) return null;
const absPath = path.join(this.rootDir, token.imageRelPath);
return { absPath, mime: mimeFromExt(path.extname(token.imageRelPath)) };
}
getImageUrl(id: TokenId): string | null {
if (!this.getImageReadInfo(id)) return null;
return `dnd://token?id=${encodeURIComponent(id)}`;
}
absPathForRel(relPath: string): string {
return path.join(this.rootDir, relPath);
}
async upsert(input: { id?: TokenId | null; name: string; filePath?: string | null }): Promise<AppToken> {
await this.ensureLoaded();
const name = input.name.trim();
if (!name) throw new Error('Token name is required');
const existing = input.id ? this.getById(input.id) : null;
if (input.id && !existing) throw new Error('Token not found');
if (!existing && !input.filePath) throw new Error('Token image is required');
let imageRelPath = existing?.imageRelPath ?? '';
let sha256 = existing?.sha256 ?? '';
if (input.filePath) {
let buf = await fs.readFile(input.filePath);
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
/* keep original */
}
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = existing?.id ?? randomTokenId();
const ext = path.extname(input.filePath) || '.png';
const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`;
imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
const abs = path.join(this.rootDir, imageRelPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
if (existing && existing.imageRelPath !== imageRelPath) {
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
const token: AppToken = {
id,
name,
imageRelPath,
sha256,
};
if (existing) {
this.tokens = this.tokens.map((t) => (t.id === id ? token : t));
} else {
this.tokens = [...this.tokens, token];
}
await this.persist();
return token;
}
const token: AppToken = {
id: existing!.id,
name,
imageRelPath,
sha256,
};
this.tokens = this.tokens.map((t) => (t.id === token.id ? token : t));
await this.persist();
return token;
}
/** Импорт токена из внешнего файла (storyline zip) с заданным id или дедупом по sha256. */
async importFromFile(input: {
preferredId: TokenId;
name: string;
absFilePath: string;
sha256?: string;
}): Promise<{ token: AppToken; remappedFrom: TokenId }> {
await this.ensureLoaded();
let buf = await fs.readFile(input.absFilePath);
const sha256 = input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
const existingByHash = this.findBySha256(sha256);
if (existingByHash) {
return { token: existingByHash, remappedFrom: input.preferredId };
}
const existingById = this.getById(input.preferredId);
const id = existingById ? randomTokenId() : input.preferredId;
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
/* keep */
}
const ext = path.extname(input.absFilePath) || '.png';
const fileName = `${id}_${safeFileBase(input.name)}${ext.toLowerCase()}`;
const imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
const abs = path.join(this.rootDir, imageRelPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
const token: AppToken = {
id,
name: input.name.trim() || 'Token',
imageRelPath,
sha256: crypto.createHash('sha256').update(buf).digest('hex'),
};
this.tokens = [...this.tokens, token];
await this.persist();
return { token, remappedFrom: input.preferredId };
}
async delete(id: TokenId): Promise<void> {
await this.ensureLoaded();
const existing = this.getById(id);
if (!existing) return;
this.tokens = this.tokens.filter((t) => t.id !== id);
await this.persist();
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
/** Упаковать выбранные токены в каталог экспорта (`app-tokens/`). */
async packForExport(tokenIds: string[], exportRoot: string): Promise<void> {
await this.ensureLoaded();
const idSet = new Set(tokenIds);
const selected = this.tokens.filter((t) => idSet.has(t.id));
if (selected.length === 0) return;
const outDir = path.join(exportRoot, 'app-tokens');
const filesOut = path.join(outDir, 'files');
await fs.mkdir(filesOut, { recursive: true });
const packed: AppToken[] = [];
for (const t of selected) {
const src = path.join(this.rootDir, t.imageRelPath);
const base = path.basename(t.imageRelPath);
const destRel = path.join('files', base).replace(/\\/gu, '/');
await fs.copyFile(src, path.join(outDir, destRel));
packed.push({ ...t, imageRelPath: destRel });
}
await fs.writeFile(
path.join(outDir, 'tokens.json'),
`${JSON.stringify({ tokens: packed }, null, 2)}\n`,
'utf8',
);
}
/**
* Импорт из `sourceCache/app-tokens/`.
* @returns map oldTokenId → newTokenId
*/
async importFromExportDir(sourceCache: string): Promise<Map<string, string>> {
await this.ensureLoaded();
const remap = new Map<string, string>();
const manifestPath = path.join(sourceCache, 'app-tokens', 'tokens.json');
let raw: string;
try {
raw = await fs.readFile(manifestPath, 'utf8');
} catch {
return remap;
}
let parsed: TokensManifest;
try {
parsed = JSON.parse(raw) as TokensManifest;
} catch {
return remap;
}
const list = Array.isArray(parsed.tokens) ? parsed.tokens : [];
for (const t of list) {
if (!t?.id || !t.imageRelPath) continue;
const abs = path.join(sourceCache, 'app-tokens', t.imageRelPath);
const { token, remappedFrom } = await this.importFromFile({
preferredId: asTokenId(t.id),
name: typeof t.name === 'string' ? t.name : 'Token',
absFilePath: abs,
...(typeof t.sha256 === 'string' ? { sha256: t.sha256 } : {}),
});
remap.set(remappedFrom, token.id);
}
return remap;
}
private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
const payload: TokensManifest = { tokens: this.tokens };
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
}
+157 -34
View File
@@ -1,10 +1,12 @@
import { app, dialog } from 'electron';
import { app, BrowserWindow, dialog } from 'electron';
import { autoUpdater } from 'electron-updater';
import { appDisplayNameForLocale } from '../../shared/appBranding';
import {
ipcChannels,
type UpdaterCheckResponse,
type UpdaterDownloadResponse,
type UpdaterProgressEvent,
} from '../../shared/ipc/contracts';
import type { IpcRegisterHandler } from '../ipc/router';
import { addLicenseChangeListener } from '../license/licenseService';
@@ -13,13 +15,63 @@ import type { LicenseService } from '../license/licenseService';
const STARTUP_CHECK_DELAY_MS = 12_000;
/** Не дёргать сервер чаще (смена лицензии / повторные emit). */
const RE_CHECK_COOLDOWN_MS = 30_000;
/** Ручная загрузка из модалки — не зависать бесконечно на «Загрузка…». */
const MANUAL_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
function withTimeout<T>(promise: Promise<T>, ms: number, code: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(code)), ms);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e: unknown) => {
clearTimeout(timer);
reject(e instanceof Error ? e : new Error(String(e)));
},
);
});
}
/**
* На macOS Squirrel.Mac может уже получить update-downloaded к моменту quitAndInstall.
* При autoInstallOnAppQuit=true MacUpdater не вызывает checkForUpdates повторно и зависает.
*/
function quitAndInstallForPlatform(): void {
if (process.platform === 'darwin') {
autoUpdater.autoInstallOnAppQuit = false;
}
autoUpdater.quitAndInstall(false, true);
}
function formatUpdaterError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e);
if (raw === 'UPDATE_DOWNLOAD_TIMEOUT') {
return 'Превышено время ожидания загрузки обновления';
}
return raw;
}
let lastCheckAt = 0;
/** Ручная установка: не показывать второй диалог из `update-downloaded`. */
let suppressAutoInstallDialog = false;
/** Версия, уже скачанная Squirrel/electron-updater (в т.ч. фоном). */
let downloadedUpdateVersion: string | null = null;
type RegisterFn = IpcRegisterHandler;
function emitUpdaterProgress(ev: UpdaterProgressEvent): void {
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
try {
win.webContents.send(ipcChannels.updater.progress, ev);
} catch {
/* окно закрылось */
}
}
}
function isLicensedForUpdates(licenseService: LicenseService): boolean {
const snap = licenseService.getStatusSync();
return snap.active;
@@ -31,6 +83,7 @@ function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: bo
const now = Date.now();
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
lastCheckAt = now;
emitUpdaterProgress({ phase: 'checking' });
void autoUpdater.checkForUpdates().catch(() => undefined);
}
@@ -41,42 +94,132 @@ async function runManualUpdaterCheck(licenseService: LicenseService): Promise<Up
if (!isLicensedForUpdates(licenseService)) {
return { outcome: 'no_license' };
}
const prevAutoDownload = autoUpdater.autoDownload;
autoUpdater.autoDownload = false;
emitUpdaterProgress({ phase: 'checking' });
try {
const result = await autoUpdater.checkForUpdates();
if (result && result.isUpdateAvailable && result.updateInfo.version) {
emitUpdaterProgress({ phase: 'available', version: result.updateInfo.version });
return { outcome: 'available', version: result.updateInfo.version };
}
emitUpdaterProgress({ phase: 'not-available', version: app.getVersion() });
return { outcome: 'current', currentVersion: app.getVersion() };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
emitUpdaterProgress({ phase: 'error', message });
return { outcome: 'error', message };
} finally {
autoUpdater.autoDownload = prevAutoDownload;
}
}
async function runManualDownloadAndRestart(): Promise<UpdaterDownloadResponse> {
function isUpdateAlreadyDownloaded(targetVersion: string): boolean {
return downloadedUpdateVersion !== null && downloadedUpdateVersion === targetVersion;
}
async function runManualDownloadAndRestart(targetVersion: string): Promise<UpdaterDownloadResponse> {
if (!app.isPackaged) {
return { ok: false, message: 'NOT_PACKAGED' };
}
const prevAutoInstallOnAppQuit = autoUpdater.autoInstallOnAppQuit;
try {
suppressAutoInstallDialog = true;
await autoUpdater.downloadUpdate();
autoUpdater.quitAndInstall(false, true);
if (process.platform === 'darwin') {
autoUpdater.autoInstallOnAppQuit = false;
}
if (!isUpdateAlreadyDownloaded(targetVersion)) {
emitUpdaterProgress({ phase: 'downloading', version: targetVersion, percent: 0 });
await withTimeout(
autoUpdater.downloadUpdate(),
MANUAL_DOWNLOAD_TIMEOUT_MS,
'UPDATE_DOWNLOAD_TIMEOUT',
);
}
emitUpdaterProgress({ phase: 'installing', version: targetVersion });
quitAndInstallForPlatform();
return { ok: true };
} catch (e) {
suppressAutoInstallDialog = false;
const message = e instanceof Error ? e.message : String(e);
const message = formatUpdaterError(e);
emitUpdaterProgress({ phase: 'error', message });
if (process.platform === 'darwin') {
autoUpdater.autoInstallOnAppQuit = prevAutoInstallOnAppQuit;
}
return { ok: false, message };
}
}
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService));
register(ipcChannels.updater.downloadAndRestart, () => runManualDownloadAndRestart());
register(ipcChannels.updater.downloadAndRestart, (req: { version: string }) =>
runManualDownloadAndRestart(req.version),
);
}
function wireAutoUpdaterEvents(licenseService: LicenseService): void {
autoUpdater.on('checking-for-update', () => {
emitUpdaterProgress({ phase: 'checking' });
});
autoUpdater.on('update-available', (info) => {
emitUpdaterProgress({ phase: 'available', version: info.version });
});
autoUpdater.on('update-not-available', (info) => {
emitUpdaterProgress({ phase: 'not-available', version: info.version });
});
autoUpdater.on('download-progress', (progress) => {
const percent =
typeof progress.percent === 'number' && Number.isFinite(progress.percent)
? Math.round(progress.percent)
: undefined;
const ev: UpdaterProgressEvent = { phase: 'downloading' };
if (percent !== undefined) ev.percent = percent;
emitUpdaterProgress(ev);
});
autoUpdater.on('update-downloaded', (info) => {
downloadedUpdateVersion = info.version;
emitUpdaterProgress({ phase: 'downloading', version: info.version, percent: 100 });
if (suppressAutoInstallDialog) {
suppressAutoInstallDialog = false;
return;
}
void dialog
.showMessageBox({
type: 'info',
title: appDisplayNameForLocale(app.getLocale()),
message: `Доступна новая версия ${info.version}. Установить и перезапустить?`,
buttons: ['Перезапустить сейчас', 'Позже'],
defaultId: 0,
cancelId: 1,
})
.then((r) => {
if (r.response === 0) {
emitUpdaterProgress({ phase: 'installing', version: info.version });
quitAndInstallForPlatform();
}
});
});
autoUpdater.on('error', (e) => {
const message = e instanceof Error ? e.message : String(e);
emitUpdaterProgress({ phase: 'error', message });
});
addLicenseChangeListener(() => {
maybeCheckForUpdates(licenseService, false);
});
}
/**
* Проверка обновлений: только упакованное приложение, только при активной лицензии.
* Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки).
* Дифференциальное скачивание (HTTP Range / blockmap) по умолчанию **выключено**: за nginx у Gitea raw часто **400** на multi-Range, updater всё равно уходит в полный файл. Включить снова: **`DND_UPDATE_ENABLE_DIFFERENTIAL=1`** (имеет смысл только если на сервере починили Range).
*/
export function installAutoUpdater(licenseService: LicenseService, register: RegisterFn): void {
registerUpdaterHandlers(register, licenseService);
@@ -89,37 +232,17 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg
autoUpdater.setFeedURL({ provider: 'generic', url });
}
const enableDiff = process.env.DND_UPDATE_ENABLE_DIFFERENTIAL?.trim().toLowerCase();
autoUpdater.disableDifferentialDownload = !(
enableDiff === '1' ||
enableDiff === 'true' ||
enableDiff === 'yes'
);
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-downloaded', (info) => {
if (suppressAutoInstallDialog) {
suppressAutoInstallDialog = false;
return;
}
void dialog
.showMessageBox({
type: 'info',
title: 'DNDGamePlayer',
message: `Доступна новая версия ${info.version}. Установить и перезапустить?`,
buttons: ['Перезапустить сейчас', 'Позже'],
defaultId: 0,
cancelId: 1,
})
.then((r) => {
if (r.response === 0) {
autoUpdater.quitAndInstall(false, true);
}
});
});
autoUpdater.on('error', () => {
/* без console: в production main минифицируется с drop console */
});
addLicenseChangeListener(() => {
maybeCheckForUpdates(licenseService, false);
});
wireAutoUpdaterEvents(licenseService);
setTimeout(() => {
maybeCheckForUpdates(licenseService, true);
+3 -1
View File
@@ -2,6 +2,7 @@ import path from 'node:path';
import { app, BrowserWindow } from 'electron';
import { appDisplayNameForLocale, windowChromeTitle } from '../../shared/appBranding';
import { getAppSemanticVersion } from '../versionInfo';
import { loadBrandingWindowIcon } from './brandingIcon';
@@ -63,6 +64,7 @@ export function createBootWindow(): BrowserWindow {
/* ignore */
}
}
win.setTitle(windowChromeTitle('boot', app.getLocale()));
bootSplashRef = win;
win.once('closed', () => {
@@ -93,7 +95,7 @@ export function setBootWindowStatus(win: BrowserWindow, text: string): void {
export function applyBootWindowBranding(win: BrowserWindow): void {
if (win.isDestroyed()) return;
const name = app.getName();
const name = appDisplayNameForLocale(app.getLocale());
const version = getAppSemanticVersion();
const versionLabel = version.trim().length > 0 ? `v${version.trim()}` : '';
void win.webContents.executeJavaScript(
@@ -35,6 +35,40 @@ void test('createWindows: пульт поверх экрана просмотр
assert.ok(src.includes("createWindow('control'"));
});
void test('createWindows: окно описания сцены закрывается с multi-window и отдельно', () => {
const src = readCreateWindows();
assert.ok(src.includes('openSceneDescriptionWindow'));
assert.ok(src.includes('closeSceneDescriptionWindow'));
assert.ok(src.includes("createWindow('sceneDescription'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeSceneDescriptionWindow/);
assert.match(src, /kind !== 'presentation'[\s\S]*closeSceneDescriptionWindow/);
});
void test('createWindows: окно материалов закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openMaterialsWindow'));
assert.ok(src.includes('closeMaterialsWindow'));
assert.ok(src.includes("createWindow('materials'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
});
void test('createWindows: окно НПС закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openNpcsWindow'));
assert.ok(src.includes('closeNpcsWindow'));
assert.ok(src.includes('openNpcsEditorWindow'));
assert.ok(src.includes('warmNpcsEditorWindow'));
assert.ok(src.includes("createWindow('npcs'"));
assert.ok(src.includes("createWindow('npcsEditor'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
});
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
const src = readCreateWindows();
assert.match(src, /kind === 'control'/);
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/);
});
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
const src = readCreateWindows();
assert.ok(src.includes('loadFile'));
@@ -46,3 +80,10 @@ void test('createWindows: показ окна — не только ready-to-sho
assert.ok(src.includes('ensureWindowBecomesVisible'));
assert.ok(src.includes('did-finish-load'));
});
void test('createWindows: логи окон не валят main через EPIPE', () => {
const src = readCreateWindows();
assert.ok(src.includes('safeConsoleError'));
assert.ok(src.includes('errorCode === -3'));
assert.ok(src.includes('isMainFrame'));
});
+505 -14
View File
@@ -2,16 +2,59 @@ import path from 'node:path';
import { app, BrowserWindow, screen } from 'electron';
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
import { ipcChannels } from '../../shared/ipc/contracts';
import { safeConsoleError } from '../safeConsole';
import { getBootSplashWindow } from './bootWindow';
import { loadBrandingWindowIcon } from './brandingIcon';
type WindowKind = 'editor' | 'presentation' | 'control';
export type WindowKind =
| 'editor'
| 'presentation'
| 'control'
| 'sceneDescription'
| 'materials'
| 'npcsEditor'
| 'sceneEditor'
| 'npcs';
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
'presentation',
'control',
'materials',
'npcs',
'npcsEditor',
'sceneEditor',
] as const;
const windows = new Map<WindowKind, BrowserWindow>();
/** Язык заголовков окон (из редактора); иначе `app.getLocale()`. */
let chromeLocaleTagOverride: string | null = null;
function resolveChromeLocaleTag(): string {
return chromeLocaleTagOverride ?? app.getLocale();
}
let appQuitting = false;
/** Защита от каскада close(control) ↔ close(presentation). */
let closingPlaySession = false;
let pendingSceneDescriptionHtml = '';
/** Окно материалов — только колонка списка. */
const MATERIALS_WINDOW_WIDTH = 300;
const MATERIALS_WINDOW_HEIGHT = 720;
/** Редактор НПС — как основной редактор, шире инспектор. */
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
/** Пульт НПС: деталь слева + список справа. */
const NPCS_WINDOW_WIDTH = 720;
const NPCS_WINDOW_HEIGHT = 720;
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
function broadcastMultiWindowStateChanged(open: boolean): void {
@@ -26,11 +69,111 @@ function broadcastMultiWindowStateChanged(open: boolean): void {
}
}
export function getPresentationContentSize(): { width: number; height: number } | null {
const pres = windows.get('presentation');
if (!pres || pres.isDestroyed()) return null;
const [width, height] = pres.getContentSize();
if (width <= 0 || height <= 0) return null;
return { width, height };
}
function broadcastPresentationContentSize(): void {
const size = getPresentationContentSize();
if (!size) return;
for (const w of BrowserWindow.getAllWindows()) {
if (w.isDestroyed() || w.webContents.isDestroyed()) continue;
try {
w.webContents.send(ipcChannels.windows.presentationContentSizeChanged, size);
} catch {
/* ignore */
}
}
}
function bindPresentationContentSizeTracking(win: BrowserWindow): void {
const emit = () => broadcastPresentationContentSize();
win.on('resize', emit);
win.on('enter-full-screen', emit);
win.on('leave-full-screen', emit);
win.webContents.once('did-finish-load', emit);
}
function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
try {
win.webContents.send(ipcChannels.windows.sceneDescriptionContent, { html });
} catch {
/* ignore */
}
}
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
export function markAppQuitting(): void {
appQuitting = true;
}
/** Точечная рассылка в известные окна приложения (без splash / чужих BrowserWindow). */
export function sendToAppWindows(
channel: string,
payload: unknown,
kinds: readonly WindowKind[] = SESSION_STATE_WINDOW_KINDS,
): void {
for (const kind of kinds) {
const win = windows.get(kind);
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) continue;
try {
win.webContents.send(channel, payload);
} catch {
/* окно могло закрыться между проверкой и send */
}
}
}
function applyWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
if (win.isDestroyed()) return;
win.setTitle(windowChromeTitle(kind as AppWindowKind, resolveChromeLocaleTag()));
}
export function syncAllWindowChromeTitles(localeTag: string): void {
chromeLocaleTagOverride = localeTag.trim() || null;
for (const [kind, win] of windows.entries()) {
applyWindowChromeTitle(win, kind);
}
}
function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
const apply = () => applyWindowChromeTitle(win, kind);
apply();
win.webContents.on('page-title-updated', (event) => {
event.preventDefault();
apply();
});
win.webContents.on('did-finish-load', () => {
apply();
});
}
/** Закрыть окна сессии, кроме редактора и его дочерних окон. */
function closePlaySessionAuxiliaryWindows(): void {
closeSceneDescriptionWindow();
closeMaterialsWindow();
closeNpcsWindow();
}
function closePresentationWindow(): void {
const pres = windows.get('presentation');
if (pres && !pres.isDestroyed()) {
pres.close();
}
}
function closeControlWindow(): void {
const ctrl = windows.get('control');
if (ctrl && !ctrl.isDestroyed()) {
ctrl.close();
}
}
function quitAppFromEditorClose(): void {
markAppQuitting();
app.quit();
@@ -50,6 +193,27 @@ function getRendererHtmlPath(kind: WindowKind): string {
return path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
}
function pageNameForKind(kind: WindowKind): string {
switch (kind) {
case 'editor':
return 'editor.html';
case 'presentation':
return 'presentation.html';
case 'control':
return 'control.html';
case 'sceneDescription':
return 'sceneDescription.html';
case 'materials':
return 'materials.html';
case 'npcsEditor':
return 'npcsEditor.html';
case 'sceneEditor':
return 'sceneEditor.html';
case 'npcs':
return 'npcs.html';
}
}
/**
* В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран;
* `loadFile` корректно открывает HTML из asar и на Windows, и на macOS.
@@ -57,9 +221,7 @@ function getRendererHtmlPath(kind: WindowKind): string {
function loadWindowPage(win: BrowserWindow, kind: WindowKind): void {
const dev = process.env.VITE_DEV_SERVER_URL;
if (dev) {
const page =
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
void win.loadURL(new URL(page, dev).toString());
void win.loadURL(new URL(pageNameForKind(kind), dev).toString());
return;
}
void win.loadFile(getRendererHtmlPath(kind));
@@ -84,7 +246,7 @@ export function applyDockIconIfNeeded(): void {
type CreateWindowOpts = {
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
parent?: BrowserWindow;
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
deferVisibility?: boolean;
};
@@ -111,12 +273,65 @@ function ensureWindowBecomesVisible(win: BrowserWindow): void {
});
}
function windowSizeForKind(kind: WindowKind): { width: number; height: number } {
if (kind === 'editor') return { width: 1280, height: 800 };
if (kind === 'control') return { width: 1200, height: 800 };
if (kind === 'sceneDescription') return { width: 720, height: 640 };
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
return { width: 1280, height: 800 };
}
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
const deferShow = opts?.deferVisibility === true;
const icon = loadBrandingWindowIcon();
const size = windowSizeForKind(kind);
const win = new BrowserWindow({
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
width: size.width,
height: size.height,
autoHideMenuBar: true,
...(kind === 'sceneDescription'
? {
minWidth: 520,
minHeight: 420,
}
: {}),
...(kind === 'materials'
? {
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
minWidth: 260,
maxWidth: 360,
minHeight: 480,
}
: {}),
...(kind === 'npcsEditor'
? {
width: NPCS_EDITOR_WINDOW_WIDTH,
height: NPCS_EDITOR_WINDOW_HEIGHT,
minWidth: 1100,
minHeight: 640,
}
: {}),
...(kind === 'sceneEditor'
? {
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
}
: {}),
...(kind === 'npcs'
? {
width: NPCS_WINDOW_WIDTH,
height: NPCS_WINDOW_HEIGHT,
minWidth: 560,
maxWidth: 900,
minHeight: 480,
}
: {}),
show: false,
backgroundColor: '#09090B',
...(icon ? { icon } : {}),
@@ -140,17 +355,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}
}
bindWindowChromeTitle(win, kind);
if (
kind === 'sceneDescription' ||
kind === 'materials' ||
kind === 'npcsEditor' ||
kind === 'sceneEditor' ||
kind === 'npcs'
) {
win.setMenuBarVisibility(false);
}
win.webContents.on('preload-error', (_event, preloadPath, error) => {
console.error(`[preload-error] ${preloadPath}:`, error);
safeConsoleError(`[preload-error] ${preloadPath}:`, error);
});
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
if (errorCode === -3) return;
if (!isMainFrame) return;
safeConsoleError(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
});
win.webContents.on('render-process-gone', (_event, details) => {
console.error('[render-process-gone]', details.reason, details.exitCode);
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
});
if (!deferEditor) {
if (!deferShow) {
ensureWindowBecomesVisible(win);
}
loadWindowPage(win, kind);
@@ -161,10 +390,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
quitAppFromEditorClose();
});
}
if (kind === 'control') {
win.on('close', () => {
if (appQuitting || closingPlaySession) return;
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
closePresentationWindow();
});
}
if (kind === 'presentation') {
bindPresentationContentSizeTracking(win);
win.on('close', () => {
if (appQuitting || closingPlaySession) return;
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
closeControlWindow();
});
}
win.on('closed', () => windows.delete(kind));
win.on('closed', () => {
if (kind !== 'presentation' && kind !== 'control') return;
const open = windows.has('presentation') || windows.has('control');
if (!open) {
closingPlaySession = false;
closePlaySessionAuxiliaryWindows();
}
broadcastMultiWindowStateChanged(open);
});
windows.set(kind, win);
@@ -244,19 +494,260 @@ export function openMultiWindow() {
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
}
broadcastMultiWindowStateChanged(true);
broadcastPresentationContentSize();
}
export function closeMultiWindow(): void {
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
const pres = windows.get('presentation');
const ctrl = windows.get('control');
if (pres) pres.close();
if (ctrl) ctrl.close();
if (pres && !pres.isDestroyed()) pres.close();
if (ctrl && !ctrl.isDestroyed()) ctrl.close();
if (!windows.has('presentation') && !windows.has('control')) {
closingPlaySession = false;
}
}
export function isMultiWindowOpen(): boolean {
return windows.has('presentation') || windows.has('control');
}
export function closeSceneDescriptionWindow(): void {
const win = windows.get('sceneDescription');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeMaterialsWindow(): void {
const win = windows.get('materials');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeNpcsEditorWindow(): void {
const win = windows.get('npcsEditor');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeSceneEditorWindow(): void {
const win = windows.get('sceneEditor');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeNpcsWindow(): void {
const win = windows.get('npcs');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function getSceneDescriptionContent(): string {
return pendingSceneDescriptionHtml;
}
/** Одно окно описания: переиспользовать существующее или создать новое. */
export function openSceneDescriptionWindow(html: string): void {
pendingSceneDescriptionHtml = html;
const existing = windows.get('sceneDescription');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
sendSceneDescriptionContent(existing, html);
return;
}
// Держим поверх пульта/презентации, иначе окно уходит под полноэкранный экран просмотра.
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('sceneDescription', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + (dw - width) / 2),
y: Math.round(y + (dh - height) / 2),
width,
height,
});
win.webContents.once('did-finish-load', () => {
sendSceneDescriptionContent(win, pendingSceneDescriptionHtml);
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
/** Полоса материалов: фиксированная ширина плитки, переиспользование окна. */
export function openMaterialsWindow(): void {
const existing = windows.get('materials');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
const b = existing.getBounds();
existing.setBounds({
x: b.x,
y: b.y,
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
});
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('materials', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, dw - width - 24)),
y: Math.round(y + (dh - height) / 2),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
function positionNpcsEditorWindow(win: BrowserWindow): void {
const parent = windows.get('editor');
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
}
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
export function warmNpcsEditorWindow(): void {
const existing = windows.get('npcsEditor');
if (existing && !existing.isDestroyed()) return;
const parent = windows.get('editor');
createWindow('npcsEditor', {
...(parent ? { parent } : {}),
deferVisibility: true,
});
}
/** Редактор НПС: отдельное окно с графом и инспектором. */
export function openNpcsEditorWindow(): void {
const existing = windows.get('npcsEditor');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
positionNpcsEditorWindow(existing);
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('editor');
const win = createWindow('npcsEditor', {
...(parent ? { parent } : {}),
deferVisibility: true,
});
positionNpcsEditorWindow(win);
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
win.show();
win.focus();
win.moveTop();
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.focus();
win.moveTop();
}
});
}
/** Редактор сцены: расстановка ловушек и легенды материалов. */
export function openSceneEditorWindow(): void {
const existing = windows.get('sceneEditor');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('editor');
const win = createWindow('sceneEditor', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
/** Пульт НПС: список + описание выбранного; оверлей аватара на сцене. */
export function openNpcsWindow(): void {
const existing = windows.get('npcs');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
const b = existing.getBounds();
existing.setBounds({
x: b.x,
y: b.y,
width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)),
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
});
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('npcs', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, dw - width - 24)),
y: Math.round(y + (dh - height) / 2),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function togglePresentationFullscreen(): boolean {
const pres = windows.get('presentation');
if (!pres) return false;
+7 -2
View File
@@ -1,4 +1,4 @@
import { contextBridge } from 'electron';
import { contextBridge, webUtils } from 'electron';
import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts';
@@ -10,9 +10,14 @@ export type DndApi = {
payload: IpcInvokeMap[K]['req'],
) => Promise<IpcInvokeMap[K]['res']>;
on: <K extends keyof IpcEventMap>(channel: K, listener: (payload: IpcEventMap[K]) => void) => () => void;
getPathForFile: (file: File) => string;
};
const api: DndApi = { invoke, on };
const api: DndApi = {
invoke,
on,
getPathForFile: (file) => webUtils.getPathForFile(file),
};
contextBridge.exposeInMainWorld('dnd', api);
+1 -1
View File
@@ -113,7 +113,7 @@
<div class="wrap">
<div class="card">
<img class="logo" src="./app-window-icon.png" width="72" height="72" alt="" />
<h1 class="title" data-boot-title>DNDGamePlayer</h1>
<h1 class="title" data-boot-title>TTRPG Player</h1>
<p class="subtitle">редактор и проигрыватель</p>
<p class="version" data-boot-version></p>
<p class="status" id="boot-status">Запуск…</p>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>DnD Player — Control</title>
<title>TTRPG - Control</title>
</head>
<body>
<div id="root"></div>
+287 -3
View File
@@ -4,14 +4,20 @@
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
overflow: auto;
min-height: 0;
box-sizing: border-box;
}
.remote {
padding: 12px;
height: 100%;
min-height: 0;
min-height: calc(100vh - 32px);
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.remoteTitle {
@@ -79,13 +85,114 @@
justify-content: center;
}
.bookIcon {
display: block;
color: var(--text-muted-on-dark);
}
.modalBackdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop);
border: none;
padding: 0;
margin: 0;
background: var(--color-scrim);
cursor: default;
}
.modalDialog {
position: fixed;
z-index: var(--z-modal);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 520px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
overflow: auto;
box-sizing: border-box;
min-width: 0;
border-radius: var(--radius-lg);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated);
box-shadow: var(--shadow-xl);
padding: 16px;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr);
}
.descriptionViewDialog {
width: min(720px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
grid-template-rows: auto 1fr auto;
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modalTitle {
font-weight: 900;
font-size: var(--text-lg);
}
.modalClose {
border: none;
background: var(--panel2);
color: var(--text2);
border-radius: var(--radius-sm);
width: 34px;
height: 34px;
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.modalFooter {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}
.descriptionViewBody {
min-height: 360px;
max-height: min(560px, calc(100vh - 200px));
overflow: auto;
padding: 14px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
}
.descriptionViewProse {
color: var(--text0);
font-size: var(--text-md);
line-height: 1.55;
word-break: break-word;
}
.descriptionViewEmpty {
color: var(--text2);
font-size: var(--text-sm);
}
.radiusRow {
display: grid;
grid-template-columns: 100px 1fr 44px;
grid-template-columns: 120px 1fr 44px;
align-items: center;
gap: 8px;
}
.effectsSoundRow {
margin-top: 4px;
}
.radiusLabel {
color: var(--text2);
font-size: var(--text-xs);
@@ -164,6 +271,7 @@
.historyTitle {
font-weight: 800;
min-width: 0;
}
.emptyStory {
@@ -195,9 +303,45 @@
.previewActions {
display: flex;
align-items: center;
gap: 10px;
}
.npcTokenScale {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.snapToGrid {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
cursor: pointer;
user-select: none;
}
.snapToGridLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
white-space: nowrap;
}
.npcTokenScaleLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
white-space: nowrap;
}
.npcTokenScaleRange {
width: min(140px, 22vw);
accent-color: var(--color-accent, #c9a227);
}
.videoHint {
color: var(--text2);
font-size: var(--text-xs);
@@ -249,7 +393,7 @@
.branchGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
@@ -260,6 +404,8 @@
padding: 12px;
display: grid;
gap: 10px;
min-width: 0;
max-width: 100%;
}
.branchCardHeader {
@@ -276,6 +422,63 @@
.branchName {
font-weight: 900;
min-width: 0;
}
.branchCardReturn {
border-color: rgba(0, 120, 212, 0.45);
}
.sideStoryGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.sideStoryTile {
display: grid;
gap: 8px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
}
.sideStoryTile:hover .sideStoryPreview {
border-color: rgba(0, 120, 212, 0.55);
}
.sideStoryPreview {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--scene-tile-radius);
overflow: hidden;
border: 2px solid var(--stroke);
background: #0c0c0e;
position: relative;
}
.sideStoryVideo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.sideStoryPlaceholder {
width: 100%;
height: 100%;
background: #0c0c0e;
}
.sideStoryTitle {
font-size: var(--text-xs);
font-weight: 800;
line-height: 1.25;
color: var(--text1);
}
.musicHeader {
@@ -308,10 +511,51 @@
white-space: nowrap;
}
.audioControls {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
align-items: stretch;
min-width: 132px;
}
.audioTransport {
display: flex;
gap: 10px;
flex-shrink: 0;
justify-content: flex-end;
}
.audioVolumeRow {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.audioVolumeIcon {
flex-shrink: 0;
width: 16px;
height: 16px;
color: var(--text2);
display: flex;
align-items: center;
justify-content: center;
}
.audioVolumeIcon svg {
display: block;
width: 16px;
height: 16px;
}
.audioVolume {
flex: 1;
min-width: 0;
width: 100%;
margin: 0;
accent-color: var(--accent-fill-solid);
}
.scrubFill {
@@ -371,3 +615,43 @@
.audioScrubDefault {
cursor: default;
}
.ctxMenuBackdrop {
position: fixed;
inset: 0;
z-index: 40;
border: none;
padding: 0;
margin: 0;
background: transparent;
cursor: default;
}
.ctxMenu {
position: fixed;
z-index: 41;
min-width: 200px;
padding: 6px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background: var(--color-surface-menu, #1a1e28);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.ctxItem {
text-align: left;
padding: 8px 10px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text1, #e8eaef);
font-size: 13px;
cursor: pointer;
width: 100%;
}
.ctxItem:hover {
background: rgba(255, 255, 255, 0.06);
}
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button } from '../shared/ui/controls';
import styles from './ControlApp.module.css';
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
function clampAudioGain(v: number): number {
if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v));
}
function VolumeSpeakerIcon({ gain }: { gain: number }) {
if (gain <= 0.001) {
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"
/>
</svg>
);
}
if (gain < 0.5) {
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"
/>
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
);
}
export type ControlAudioCardProps = {
assetId: string;
name: string;
autoplay: boolean;
loop: boolean;
statusLabel: string;
statusDetail?: string;
extraBadge?: React.ReactNode;
audioEl: HTMLAudioElement | null;
initialGain: number;
gainMap: Map<string, number>;
playTitle: string;
playLabel: string;
pauseLabel: string;
stopLabel: string;
volumeLabel: string;
modeAutoLabel: string;
modeManualLabel: string;
loopLabel: string;
onceLabel: string;
scrubSeekLabel: string;
durationUnknownLabel: string;
/** Редкий bump родителя (play/pause/error) — не для scrub. */
onStatusChange: () => void;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
};
/**
* Карточка трека: scrub/time обновляются локально (RAF → DOM), без ре-рендера всего ControlApp.
*/
export function ControlAudioCard({
assetId,
name,
autoplay,
loop,
statusLabel,
statusDetail,
extraBadge,
audioEl,
initialGain,
gainMap,
playTitle,
playLabel,
pauseLabel,
stopLabel,
volumeLabel,
modeAutoLabel,
modeManualLabel,
loopLabel,
onceLabel,
scrubSeekLabel,
durationUnknownLabel,
onStatusChange,
onPlay,
onPause,
onStop,
}: ControlAudioCardProps) {
const scrubRef = useRef<HTMLDivElement | null>(null);
const scrubFillRef = useRef<HTMLDivElement | null>(null);
const curTimeRef = useRef<HTMLDivElement | null>(null);
const durTimeRef = useRef<HTMLDivElement | null>(null);
const [gainUi, setGainUi] = useState(() => clampAudioGain(initialGain));
const onStatusChangeRef = useRef(onStatusChange);
onStatusChangeRef.current = onStatusChange;
useEffect(() => {
setGainUi(clampAudioGain(gainMap.get(assetId) ?? initialGain));
}, [assetId, gainMap, initialGain]);
useEffect(() => {
if (!audioEl) return;
let raf = 0;
const paint = (): void => {
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
const cur = audioEl.currentTime && Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0;
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
if (scrubFillRef.current) {
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
if (durTimeRef.current) durTimeRef.current.textContent = dur ? formatTime(dur) : '—:—';
if (scrubRef.current) {
scrubRef.current.setAttribute('aria-valuemin', '0');
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
scrubRef.current.title = dur > 0 ? scrubSeekLabel : durationUnknownLabel;
scrubRef.current.classList.toggle(styles.audioScrubPointer ?? 'audioScrubPointer', dur > 0);
scrubRef.current.classList.toggle(styles.audioScrubDefault ?? 'audioScrubDefault', dur <= 0);
}
};
const stopLoop = (): void => {
if (raf !== 0) {
window.cancelAnimationFrame(raf);
raf = 0;
}
};
const loopPaint = (): void => {
paint();
if (!audioEl.paused) {
raf = window.requestAnimationFrame(loopPaint);
} else {
raf = 0;
}
};
const startLoop = (): void => {
stopLoop();
raf = window.requestAnimationFrame(loopPaint);
};
const onPlayEv = (): void => {
startLoop();
onStatusChangeRef.current();
};
const onPauseEv = (): void => {
stopLoop();
paint();
onStatusChangeRef.current();
};
const onEndedEv = (): void => {
stopLoop();
paint();
onStatusChangeRef.current();
};
const onMetaEv = (): void => {
paint();
onStatusChangeRef.current();
};
audioEl.addEventListener('play', onPlayEv);
audioEl.addEventListener('pause', onPauseEv);
audioEl.addEventListener('ended', onEndedEv);
audioEl.addEventListener('canplay', onMetaEv);
audioEl.addEventListener('error', onMetaEv);
paint();
if (!audioEl.paused) startLoop();
return () => {
stopLoop();
audioEl.removeEventListener('play', onPlayEv);
audioEl.removeEventListener('pause', onPauseEv);
audioEl.removeEventListener('ended', onEndedEv);
audioEl.removeEventListener('canplay', onMetaEv);
audioEl.removeEventListener('error', onMetaEv);
};
}, [audioEl, durationUnknownLabel, scrubSeekLabel]);
const seekByClientX = (clientX: number): void => {
if (!audioEl || !scrubRef.current) return;
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
if (!dur) return;
const rect = scrubRef.current.getBoundingClientRect();
const next = (clientX - rect.left) / Math.max(1, rect.width);
audioEl.currentTime = Math.max(0, Math.min(dur, next * dur));
const cur = audioEl.currentTime;
const pct = Math.max(0, Math.min(1, cur / dur));
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
};
const applyGain = (v: number): void => {
const g = clampAudioGain(v);
gainMap.set(assetId, g);
if (audioEl) {
try {
audioEl.volume = g;
} catch {
// ignore
}
}
setGainUi(g);
};
return (
<div className={styles.audioCard}>
<div className={styles.audioMeta}>
<div className={styles.audioName}>{name}</div>
<div className={styles.audioBadges}>
<div>{autoplay ? modeAutoLabel : modeManualLabel}</div>
<div>{loop ? loopLabel : onceLabel}</div>
<div title={statusDetail}>{statusLabel}</div>
{extraBadge}
</div>
<div className={styles.spacer10} />
<div
ref={scrubRef}
role="slider"
tabIndex={0}
className={[styles.audioScrub, styles.audioScrubDefault].join(' ')}
onKeyDown={(e) => {
if (!audioEl) return;
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
if (!dur) return;
if (e.key === 'ArrowLeft') audioEl.currentTime = Math.max(0, audioEl.currentTime - 5);
if (e.key === 'ArrowRight') audioEl.currentTime = Math.min(dur, audioEl.currentTime + 5);
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(audioEl.currentTime);
const pct = Math.max(0, Math.min(1, audioEl.currentTime / dur));
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}}
onClick={(e) => seekByClientX(e.clientX)}
>
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
</div>
<div className={styles.timeRow}>
<div ref={curTimeRef}>0:00</div>
<div ref={durTimeRef}>:</div>
</div>
</div>
<div className={styles.audioControls}>
<div className={styles.audioTransport}>
<Button variant="primary" title={playTitle} ariaLabel={playLabel} onClick={onPlay}>
</Button>
<Button title={pauseLabel} ariaLabel={pauseLabel} onClick={onPause}>
</Button>
<Button
title={stopLabel}
ariaLabel={stopLabel}
onClick={() => {
onStop();
if (scrubFillRef.current) scrubFillRef.current.style.width = '0%';
if (curTimeRef.current) curTimeRef.current.textContent = '0:00';
}}
>
</Button>
</div>
<div className={styles.audioVolumeRow}>
<span className={styles.audioVolumeIcon} aria-hidden>
<VolumeSpeakerIcon gain={gainUi} />
</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={gainUi}
disabled={!audioEl}
className={styles.audioVolume}
aria-label={volumeLabel}
title={`${volumeLabel}: ${String(Math.round(gainUi * 100))}%`}
onChange={(e) => applyGain(Number(e.currentTarget.value))}
/>
</div>
</div>
</div>
);
}
+20 -6
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
import type { SceneViewCamera } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { RotatedImage } from '../shared/RotatedImage';
import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -12,6 +13,7 @@ import styles from './ControlScenePreview.module.css';
type Props = {
session: SessionState | null;
videoRef: React.RefObject<HTMLVideoElement | null>;
viewCamera?: SceneViewCamera | null;
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
};
@@ -23,7 +25,7 @@ function fmt(sec: number): string {
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
const { t } = useEditorI18n();
const [vp, video] = useVideoPlaybackState();
const scene =
@@ -33,6 +35,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
const isVideo = scene?.previewAssetType === 'video';
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
const autostart = scene?.previewVideoAutostart ?? false;
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
const [tick, setTick] = useState(0);
const dur = useMemo(
@@ -59,14 +62,18 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
useEffect(() => {
if (!isVideo) return;
if (!assetId) return;
// `target.set` bumps revision and resets anchors; avoid firing on every render.
if (vp?.targetAssetId === assetId) return;
const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
const prev = lastTargetRef.current;
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
return;
}
lastTargetRef.current = { sceneKey, assetId, autostart };
void video.dispatch({
kind: 'target.set',
assetId,
autostart,
});
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
}, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
useEffect(() => {
const v = videoRef.current;
@@ -91,7 +98,13 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
return (
<div className={styles.root}>
{url && scene?.previewAssetType === 'image' ? (
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} />
<RotatedImage
url={url}
rotationDeg={rot}
mode="contain"
viewCamera={viewCamera}
onContentRectChange={onContentRectChange}
/>
) : url && isVideo ? (
<video
ref={(el) => {
@@ -100,6 +113,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
className={styles.video}
src={url}
playsInline
loop={Boolean(scene?.settings?.loopVideo)}
preload="auto"
onTimeUpdate={() => setTick((x) => x + 1)}
onLoadedMetadata={() => setTick((x) => x + 1)}
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Регресс: RAF в ControlApp бампил оба audio-tick на каждом кадре → полный ре-рендер пульта. */
void test('ControlApp: нет per-frame RAF setState для аудио scrub', () => {
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
assert.doesNotMatch(app, /\banyPlaying\b/);
// Старый паттерн: RAF tick → оба set*AudioStateTick.
assert.doesNotMatch(
app,
/const tick = \(\) => \{\s*setSceneAudioStateTick/,
'корневой RAF-тик аудио удалён',
);
assert.doesNotMatch(app, /requestAnimationFrame\s*\(\s*tick\s*\)/);
assert.ok(app.includes('ControlAudioCard'));
assert.ok(card.includes('requestAnimationFrame'), 'scrub крутится локально в карточке');
assert.ok(card.includes('scrubFillRef'), 'прогресс пишется в DOM, не через setState корня');
assert.ok(card.includes('setGainUi'), 'громкость обновляет только карточку');
});
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Регресс: scheduleDraftRepaint бампил draftFxTick → полный ре-рендер ControlApp на кадр штриха. */
void test('ControlApp: draft кисти без корневого draftFxTick', () => {
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
const pixi = fs.readFileSync(
path.join(here, '../shared/effects/PxiEffectsOverlay.tsx'),
'utf8',
);
assert.doesNotMatch(app, /\bdraftFxTick\b/);
assert.doesNotMatch(app, /\bsetDraftFxTick\b/);
assert.doesNotMatch(app, /\bfxMergedState\b/);
assert.ok(app.includes('scheduleDraftRepaint'));
assert.ok(app.includes('pushDraftToPixi'));
assert.ok(app.includes('effectsOverlayRef'));
assert.match(app, /scheduleDraftRepaint[\s\S]*?pushDraftToPixi\(\)/);
assert.doesNotMatch(
app,
/scheduleDraftRepaint[\s\S]*?setDraftFxTick/,
'RAF draft не трогает React state корня',
);
assert.ok(pixi.includes('PixiEffectsOverlayHandle'));
assert.ok(pixi.includes('setDraft'));
assert.ok(pixi.includes('mergeDraftState'));
assert.ok(pixi.includes('forwardRef'));
});
@@ -18,6 +18,7 @@ void test('ControlApp: звук молнии (public/molniya.mp3)', () => {
const src = readControlApp();
assert.ok(src.includes('molniya.mp3'));
assert.ok(src.includes('playLightningEffectSound'));
assert.ok(src.includes('LIGHTNING_EFFECT_MS'));
});
void test('ControlApp: звук заморозки (public/zamorozka.mp3)', () => {
@@ -45,26 +46,88 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', (
assert.ok(sfxSrc.includes('playbackRate'));
});
void test('ControlApp: активация ловушки «яд» спавнит poisonCloud как на пульте эффектов', () => {
const src = readControlApp();
assert.ok(src.includes("trap?.type === 'poison'"));
assert.ok(src.includes("type: 'poisonCloud'"));
assert.ok(src.includes('trap_pc_'));
assert.ok(src.includes('playPoisonCloudEffectSound(poisonLifeMs)'));
});
void test('ControlApp: эффект «взрыв» + ловушка используют explosion webm/mp3', () => {
const appSrc = readControlApp();
const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8');
assert.ok(appSrc.includes("title={t('control.explosion')}"));
assert.ok(appSrc.includes("tool: 'explosion'"));
assert.ok(appSrc.includes("type: 'explosion'"));
assert.ok(appSrc.includes('getExplosionEffectLifeMs'));
assert.ok(appSrc.includes('playExplosionEffectSound'));
assert.ok(appSrc.includes("trap?.type === 'explosion'"));
assert.ok(appSrc.includes('trap_ex_'));
assert.ok(sfxSrc.includes('explosion.mp3'));
assert.ok(sfxSrc.includes('aerial-debris-smoke.webm'));
assert.ok(appSrc.includes('ExplosionVideoOverlay'));
const videoOverlaySrc = fs.readFileSync(
path.join(here, '..', 'shared', 'effects', 'ExplosionVideoOverlay.tsx'),
'utf8',
);
assert.ok(videoOverlaySrc.includes('explosionEffectVideoUrl'));
});
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
const src = readControlApp();
assert.ok(src.includes("t('control.instruments')"));
assert.ok(src.includes("t('control.descriptionTool')"));
assert.ok(src.includes("t('control.descriptionMissing')"));
assert.ok(src.includes('openSceneDescription'));
assert.ok(!src.includes('SceneDescriptionViewModal'));
assert.ok(src.includes("t('control.effects')"));
assert.ok(src.includes("t('control.tools')"));
assert.ok(src.includes("t('control.fieldEffects')"));
assert.ok(src.includes("t('control.actionEffects')"));
assert.ok(src.includes("t('control.darknessControl')"));
assert.ok(src.includes("t('control.explorerBrush')"));
assert.ok(src.includes("t('control.closerBrush')"));
assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
assert.ok(src.includes('SceneDarknessOverlay'));
assert.ok(src.includes('useSceneDarknessState'));
assert.ok(src.includes("t('control.sunbeam')"));
assert.ok(src.includes("t('control.lightning')"));
assert.ok(src.includes("title={t('control.water')}"));
assert.ok(src.includes("title={t('control.fire')}"));
assert.ok(src.includes("title={t('control.darkness')}"));
assert.ok(src.includes("title={t('control.poisonCloud')}"));
assert.ok(src.includes("title={t('control.fog')}"));
assert.ok(src.includes("ariaLabel={t('control.fog')}"));
assert.ok(src.includes('selectEffectTool'), 'повторный клик снимает инструмент');
assert.ok(src.includes("tool.tool === next ? 'none' : next"));
assert.ok(src.includes('iconOnly'));
assert.ok(src.includes("title={t('control.clearEffects')}"));
assert.ok(src.includes("ariaLabel={t('control.clearEffects')}"));
assert.ok(src.includes('#e5484d'));
const instruments = src.indexOf("t('control.instruments')");
const fx = src.indexOf("t('control.effects')");
const story = src.indexOf("t('control.storyLine')");
assert.ok(
instruments !== -1 && fx !== -1 && instruments < fx,
'Блок инструментов должен быть выше эффектов',
);
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
});
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
const src = readControlApp();
const css = readControlAppCss();
assert.ok(src.includes('useTokenGridSnapSession'));
assert.ok(src.includes('snapNormToGridCell'));
assert.ok(src.includes('data-testid="token-grid-snap"'));
assert.ok(src.includes("t('control.snapTokensToGrid')"));
assert.ok(src.includes('snapAllTokensToGrid'));
assert.ok(src.includes('snapNorm={snapNormActive}'));
assert.ok(css.includes('.snapToGrid'));
});
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
const src = readControlApp();
const css = readControlAppCss();
@@ -76,6 +139,32 @@ void test('ControlApp: сюжетная линия — колонка сверх
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
});
void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => {
const src = readControlApp();
assert.ok(!src.includes('suppressNextHistoryPushRef'));
assert.ok(!src.includes('arr.includes(cur)'));
assert.ok(src.includes('historyRef.current = [...arr, cur]'));
});
void test('ControlApp: текущая сцена в истории — последнее вхождение', () => {
const src = readControlApp();
assert.ok(src.includes('history.lastIndexOf(currentGraphNodeId)'));
assert.ok(src.includes('idx === currentHistoryIdx'));
});
void test('ControlApp: сюжетная линия не меняет граф и сцены проекта', () => {
const src = readControlApp();
const story = src.indexOf("t('control.storyLine')");
assert.ok(story !== -1);
const tail = src.slice(story);
assert.ok(!tail.includes('addSceneGraphNode'));
assert.ok(!tail.includes('removeSceneGraphNode'));
assert.ok(!tail.includes('addSceneGraphEdge'));
assert.ok(!tail.includes('removeSceneGraphEdge'));
assert.ok(!tail.includes('updateProject'));
assert.ok(tail.includes('ipcChannels.project.setCurrentGraphNode'));
});
void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => {
const src = readControlApp();
const css = readControlAppCss();
@@ -108,6 +197,55 @@ void test('ControlApp: музыка разделена на сцену и кам
assert.match(src, /pause campaign\./i);
});
void test('ControlApp: у каждой аудиозаписи есть регулятор громкости под транспортом', () => {
const src = readControlApp();
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
const css = readControlAppCss();
assert.ok(src.includes('ControlAudioCard'));
assert.ok(src.includes("t('control.volume')"));
assert.ok(src.includes('sceneAudioGainRef'));
assert.ok(src.includes('campaignAudioGainRef'));
assert.ok(src.includes('applyAudioGain'));
assert.ok(card.includes('VolumeSpeakerIcon'));
assert.ok(card.includes('styles.audioVolume'));
assert.ok(card.includes('styles.audioVolumeRow'));
assert.match(css, /\.audioControls[\s\S]*?flex-direction:\s*column/);
assert.match(css, /\.audioVolumeRow\b/);
assert.match(css, /\.audioVolumeIcon\b/);
assert.match(css, /\.audioVolume\b/);
});
void test('ControlApp: весь контент скроллится в окне, отступы сверху и снизу равны', () => {
const css = readControlAppCss();
assert.match(css, /\.page\s*\{[^}]*padding:\s*16px/s);
assert.match(css, /\.page\s*\{[^}]*overflow:\s*auto/s);
assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s);
});
void test('ControlApp: вода/дождь гасят огонь; ambient огня/дождя + слайдер «Звук эффектов»', () => {
const src = readControlApp();
const fireSfx = fs.readFileSync(path.join(here, 'fireAmbientSfx.ts'), 'utf8');
const rainSfx = fs.readFileSync(path.join(here, 'rainAmbientSfx.ts'), 'utf8');
const i18n = fs.readFileSync(path.join(here, '../editor/i18n/editorMessages.ts'), 'utf8');
assert.ok(src.includes('extinguishFireAlong'));
assert.ok(src.includes("types: ['fire']"));
assert.ok(src.includes("hitMode: 'brush'"));
assert.ok(src.includes('effectsSoundRow'));
assert.ok(src.includes('setFireAmbientActive'));
assert.ok(src.includes('setRainAmbientActive'));
assert.ok(src.includes('hasFireOnScene'));
assert.ok(src.includes('hasRainOnScene'));
assert.ok(src.includes("t('control.effectsSound')"));
assert.ok(fireSfx.includes('fire-ambient.mp3'));
assert.ok(rainSfx.includes('rain-ambient.mp3'));
assert.ok(i18n.includes("'control.effectsSound': 'Звук эффектов'"));
const radius = src.indexOf("t('control.brushRadius')");
const effectsSound = src.indexOf("t('control.effectsSound')");
assert.ok(radius !== -1 && effectsSound !== -1 && radius < effectsSound);
});
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
const src = readControlApp();
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
+17
View File
@@ -0,0 +1,17 @@
/** Общая громкость звуков эффектов (огонь ambient + one-shot). 0…1. */
let effectsSfxGain = 0.75;
export function clampEffectsSfxGain(v: number): number {
if (!Number.isFinite(v)) return 0.75;
return Math.max(0, Math.min(1, v));
}
export function getEffectsSfxGain(): number {
return effectsSfxGain;
}
export function setEffectsSfxGain(v: number): number {
effectsSfxGain = clampEffectsSfxGain(v);
return effectsSfxGain;
}
+63
View File
@@ -0,0 +1,63 @@
/** Звук и длительность эффекта «Взрыв» (`public/explosion.mp3` + WebM с альфой). */
import { getEffectsSfxGain } from './effectsSfxGain';
const EXPLOSION_SFX_VOLUME = 0.95;
/** Запас, если метаданные не прочитались (длина webm ~3.97 с). */
const DEFAULT_EXPLOSION_LIFE_MS = 4200;
export function explosionEffectSoundUrl(): string {
return new URL('explosion.mp3', window.location.href).href;
}
/** VP8/VP9 WebM с `alpha_mode=1` — рендер через HTML `<video>`, не Pixi. */
export function explosionEffectVideoUrl(): string {
return new URL('vfx/explosion/aerial-debris-smoke.webm', window.location.href).href;
}
let cachedExplosionSfxDurationMs: number | null = null;
/** Длительность трека в мс (кэш после первого чтения метаданных). */
export function getExplosionSfxDurationMs(): Promise<number> {
if (cachedExplosionSfxDurationMs !== null) {
return Promise.resolve(cachedExplosionSfxDurationMs);
}
const url = explosionEffectSoundUrl();
return new Promise((resolve) => {
const a = new Audio();
const done = (ms: number): void => {
cachedExplosionSfxDurationMs = ms;
a.removeAttribute('src');
resolve(ms);
};
a.addEventListener('loadedmetadata', () => {
const d = a.duration;
done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_EXPLOSION_LIFE_MS);
});
a.addEventListener('error', () => done(DEFAULT_EXPLOSION_LIFE_MS));
a.src = url;
a.load();
});
}
/** Длительность визуала ≈ max(звук, webm), с разумными пределами. */
export async function getExplosionEffectLifeMs(): Promise<number> {
const sfxMs = await getExplosionSfxDurationMs();
const raw = Math.max(sfxMs, DEFAULT_EXPLOSION_LIFE_MS);
return Math.min(60_000, Math.max(600, raw));
}
export async function playExplosionEffectSound(lifeMs: number): Promise<void> {
try {
const rawMs = await getExplosionSfxDurationMs();
const target = Math.max(200, lifeMs);
const rate = Math.max(0.25, Math.min(4, rawMs / target));
const el = new Audio(explosionEffectSoundUrl());
el.volume = Math.max(0, Math.min(1, EXPLOSION_SFX_VOLUME * getEffectsSfxGain()));
el.playbackRate = rate;
void el.play().catch(() => undefined);
} catch {
/* ignore */
}
}
+50
View File
@@ -0,0 +1,50 @@
/** Фоновый звук горения при наличии огня на сцене (`public/fire-ambient.mp3`). */
import { getEffectsSfxGain } from './effectsSfxGain';
const FIRE_AMBIENT_BASE_VOLUME = 0.72;
export function fireAmbientSoundUrl(): string {
return new URL('fire-ambient.mp3', window.location.href).href;
}
let ambientEl: HTMLAudioElement | null = null;
function ensureAmbientEl(): HTMLAudioElement {
if (ambientEl) return ambientEl;
const el = new Audio(fireAmbientSoundUrl());
el.loop = true;
el.preload = 'auto';
ambientEl = el;
return el;
}
function applyVolume(el: HTMLAudioElement): void {
el.volume = Math.max(0, Math.min(1, FIRE_AMBIENT_BASE_VOLUME * getEffectsSfxGain()));
}
/** Включить/выключить loop горения. */
export function setFireAmbientActive(active: boolean): void {
try {
const el = ensureAmbientEl();
applyVolume(el);
if (active) {
if (el.paused) void el.play().catch(() => undefined);
} else if (!el.paused) {
el.pause();
el.currentTime = 0;
}
} catch {
/* ignore */
}
}
/** Обновить громкость, если ambient уже играет. */
export function syncFireAmbientVolume(): void {
if (!ambientEl) return;
try {
applyVolume(ambientEl);
} catch {
/* ignore */
}
}
+4 -2
View File
@@ -1,9 +1,11 @@
/** Звук и длительность эффекта «Заморозка» (`public/zamorozka.mp3`). */
import { getEffectsSfxGain } from './effectsSfxGain';
const DEFAULT_FREEZE_LIFE_MS = 820;
const FREEZE_SFX_BASE_VOLUME = 0.88;
/** На 25% тише базовой громкости эффекта. */
/** На 25% тише базовой громкости эффекта (до множителя «Звук эффектов»). */
export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75;
export function freezeEffectSoundUrl(): string {
@@ -44,7 +46,7 @@ export async function getFreezeEffectLifeMs(): Promise<number> {
export function playFreezeEffectSound(): void {
try {
const el = new Audio(freezeEffectSoundUrl());
el.volume = FREEZE_SFX_VOLUME;
el.volume = Math.max(0, Math.min(1, FREEZE_SFX_VOLUME * getEffectsSfxGain()));
void el.play().catch(() => undefined);
} catch {
/* ignore */
+3
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { ControlApp } from './ControlApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Пульт">
<EditorI18nProvider>
<ControlApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
+3 -1
View File
@@ -1,5 +1,7 @@
/** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */
import { getEffectsSfxGain } from './effectsSfxGain';
const POISON_CLOUD_SFX_VOLUME = 0.92;
/** Запас, если метаданные не прочитались. */
@@ -50,7 +52,7 @@ export async function playPoisonCloudEffectSound(lifeMs: number): Promise<void>
const target = Math.max(200, lifeMs);
const rate = Math.max(0.25, Math.min(4, rawMs / target));
const el = new Audio(poisonCloudEffectSoundUrl());
el.volume = POISON_CLOUD_SFX_VOLUME;
el.volume = Math.max(0, Math.min(1, POISON_CLOUD_SFX_VOLUME * getEffectsSfxGain()));
el.playbackRate = rate;
void el.play().catch(() => undefined);
} catch {
+50
View File
@@ -0,0 +1,50 @@
/** Фоновый звук дождя при наличии дождя на сцене (`public/rain-ambient.mp3`). */
import { getEffectsSfxGain } from './effectsSfxGain';
const RAIN_AMBIENT_BASE_VOLUME = 0.7;
export function rainAmbientSoundUrl(): string {
return new URL('rain-ambient.mp3', window.location.href).href;
}
let ambientEl: HTMLAudioElement | null = null;
function ensureAmbientEl(): HTMLAudioElement {
if (ambientEl) return ambientEl;
const el = new Audio(rainAmbientSoundUrl());
el.loop = true;
el.preload = 'auto';
ambientEl = el;
return el;
}
function applyVolume(el: HTMLAudioElement): void {
el.volume = Math.max(0, Math.min(1, RAIN_AMBIENT_BASE_VOLUME * getEffectsSfxGain()));
}
/** Включить/выключить loop дождя. */
export function setRainAmbientActive(active: boolean): void {
try {
const el = ensureAmbientEl();
applyVolume(el);
if (active) {
if (el.paused) void el.play().catch(() => undefined);
} else if (!el.paused) {
el.pause();
el.currentTime = 0;
}
} catch {
/* ignore */
}
}
/** Обновить громкость, если ambient уже играет. */
export function syncRainAmbientVolume(): void {
if (!ambientEl) return;
try {
applyVolume(ambientEl);
} catch {
/* ignore */
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
/** Звук эффекта «Луч света» (`public/luch_sveta.mp3`). */
import { getEffectsSfxGain } from './effectsSfxGain';
const SUNBEAM_SFX_VOLUME = 0.88;
/** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */
export const SUNBEAM_PLAYBACK_RATE = 1.5;
@@ -47,7 +49,7 @@ export async function getSunbeamEffectLifeMs(): Promise<number> {
export function playSunbeamEffectSound(): void {
try {
const el = new Audio(sunbeamEffectSoundUrl());
el.volume = SUNBEAM_SFX_VOLUME;
el.volume = Math.max(0, Math.min(1, SUNBEAM_SFX_VOLUME * getEffectsSfxGain()));
el.playbackRate = SUNBEAM_PLAYBACK_RATE;
void el.play().catch(() => undefined);
} catch {
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>DnD Player — Editor</title>
<title>TTRPG - Editor</title>
</head>
<body>
<div id="root"></div>
+627 -18
View File
@@ -59,15 +59,89 @@
gap: 10px;
}
.splitRun {
display: inline-flex;
align-items: stretch;
height: 34px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent-border);
background: var(--accent-fill-solid);
overflow: hidden;
}
.splitRun:disabled,
.splitRun[aria-disabled='true'] {
opacity: 0.45;
pointer-events: none;
}
.splitRunMain,
.splitRunChevron {
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.95);
cursor: pointer;
font: inherit;
font-weight: 600;
}
.splitRunMain {
padding: 0 14px;
}
.splitRunMain:hover:not(:disabled),
.splitRunChevron:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent-fill-solid) 72%, white);
}
.splitRunMain:active:not(:disabled),
.splitRunChevron:active:not(:disabled),
.splitRunChevronOpen {
background: color-mix(in srgb, var(--accent-fill-solid) 78%, black);
}
.splitRunDivider {
width: 1px;
align-self: stretch;
background: rgba(0, 0, 0, 0.28);
flex: 0 0 auto;
}
.splitRunChevron {
width: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.splitRunChevronSvg {
width: 12px;
height: 12px;
display: block;
}
.editorSidebar {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
display: flex;
flex-direction: column;
gap: 0;
padding: 12px;
border-right: 1px solid var(--stroke);
background: var(--editor-column-bg);
overflow: hidden;
position: relative;
transition:
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
}
.editorSidebarDragOver {
border-right-color: var(--accent);
background: var(--accent-fill-soft);
box-shadow: inset 0 0 0 2px var(--accent);
}
.editorGraphHost {
@@ -94,25 +168,62 @@
.gridTools {
display: grid;
gap: 10px;
flex: 0 0 auto;
}
.gridTools > * {
width: 100%;
max-width: 100%;
display: flex;
box-sizing: border-box;
}
.gridTools button {
width: 100%;
box-sizing: border-box;
flex: 1;
}
.spacer14 {
height: 14px;
flex: 0 0 auto;
}
.spacer18 {
height: 18px;
}
.gamePropsButtons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.gamePropsButtons > * {
width: 100%;
min-width: 0;
max-width: 100%;
display: flex;
box-sizing: border-box;
}
.gamePropsButtons button {
width: 100%;
box-sizing: border-box;
flex: 1;
}
.sidebarScroll {
overflow: auto;
padding-right: 2px;
min-height: 0;
flex: 1 1 auto;
align-self: stretch;
}
.sceneListGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
align-content: start;
justify-items: stretch;
@@ -131,7 +242,8 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
/* Выше модалок (20001), чтобы прогресс импорта не уходил под диалог выбора. */
z-index: 30000;
}
.editorLockOverlay {
@@ -169,15 +281,19 @@
.progressModal {
width: min(520px, calc(100vw - 32px));
border-radius: 12px;
padding: 14px;
padding: 16px 16px 20px;
background: rgba(25, 28, 38, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
display: grid;
gap: 12px;
justify-items: center;
}
.progressTitle {
font-weight: 700;
margin-bottom: 10px;
width: 100%;
text-align: center;
}
.progressBar {
@@ -186,6 +302,7 @@
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
}
.progressFill {
@@ -195,11 +312,13 @@
}
.progressMeta {
margin-top: 10px;
margin-top: 0;
display: flex;
justify-content: space-between;
gap: 12px;
opacity: 0.9;
font-size: 12px;
width: 100%;
}
.inspectorTitle {
@@ -208,6 +327,18 @@
flex-shrink: 0;
}
.projectNameLabel {
font-weight: 700;
font-size: 13px;
letter-spacing: 0.02em;
margin-bottom: 10px;
color: var(--text1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.inspectorScroll {
flex: 1;
min-height: 0;
@@ -219,6 +350,11 @@
color: var(--text2);
}
.noticeMessage {
white-space: pre-wrap;
line-height: 1.45;
}
.fileMenu {
position: fixed;
min-width: 220px;
@@ -243,6 +379,17 @@
font: inherit;
}
.fileMenuItemDanger {
text-align: left;
padding: 10px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--color-danger);
cursor: pointer;
font: inherit;
}
.fileMenuSubHost {
position: relative;
}
@@ -289,6 +436,10 @@
transform: translate(-50%, -50%);
width: 520px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
overflow: auto;
box-sizing: border-box;
min-width: 0;
border-radius: var(--radius-lg);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated);
@@ -296,6 +447,7 @@
padding: 16px;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr);
}
.modalHeader {
@@ -325,6 +477,17 @@
.fieldGrid {
display: grid;
gap: 6px;
min-width: 0;
}
.fieldGroup {
display: grid;
gap: 6px;
min-width: 0;
}
.fieldGroupSpaced {
margin-top: 10px;
}
.fieldLabel {
@@ -338,17 +501,6 @@
font-size: var(--text-xs);
}
.selectInput {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--bg0);
color: var(--text0);
font: inherit;
}
.rowFlex {
display: flex;
gap: 8px;
@@ -358,8 +510,13 @@
.modalFooter {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.licenseBlockTitle {
@@ -393,6 +550,153 @@
line-height: 1.45;
}
.instructionsDialog {
position: fixed;
z-index: var(--z-modal);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(920px, calc(100vw - 32px));
max-height: min(86vh, 720px);
border-radius: var(--radius-lg);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated);
box-shadow: var(--shadow-xl);
padding: 16px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 12px;
}
.instructionsLayout {
display: grid;
grid-template-columns: 1fr 220px;
gap: 14px;
min-height: 0;
max-height: min(62vh, 560px);
}
.instructionsContent {
min-height: 0;
overflow: auto;
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--bg0);
}
.instructionsContentTitle {
font-weight: 800;
font-size: var(--text-md);
margin-bottom: 12px;
}
.instructionsParagraph {
margin: 0 0 12px;
color: var(--text1);
font-size: var(--text-sm);
line-height: 1.55;
white-space: pre-wrap;
}
.instructionsParagraph:last-child {
margin-bottom: 0;
}
.instructionsInlineLink {
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
color: var(--accent, #a78bfa);
font: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.instructionsInlineLink:hover {
color: var(--text0);
}
.instructionsNav {
min-height: 0;
overflow: auto;
display: grid;
gap: 4px;
align-content: start;
padding: 6px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--panel2);
}
.instructionsNavItem {
text-align: left;
padding: 9px 10px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
font: inherit;
font-size: var(--text-xs);
line-height: 1.35;
}
.instructionsNavItem:hover {
background: var(--scene-list-hover-bg);
color: var(--text0);
}
.instructionsNavItemActive {
background: rgba(167, 139, 250, 0.18);
color: var(--text0);
font-weight: 700;
}
.aboutBody {
display: grid;
gap: 10px;
}
.aboutAppName {
font-weight: 900;
font-size: var(--text-lg);
}
.aboutTagline {
color: var(--text2);
font-size: var(--text-sm);
line-height: 1.45;
}
.aboutParagraph {
margin: 0;
color: var(--text1);
font-size: var(--text-sm);
line-height: 1.55;
}
.aboutMetaGrid {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 14px;
align-items: baseline;
margin-top: 4px;
}
.aboutLink {
color: var(--color-accent, #a78bfa);
text-decoration: none;
}
.aboutLink:hover {
text-decoration: underline;
}
.fileSuffix {
color: var(--text2);
font-size: var(--text-xs);
@@ -414,6 +718,23 @@
gap: 10px;
}
.projectPickerForm > * {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.projectPickerForm > *:has(button),
.projectPickerForm > span {
display: flex;
}
.projectPickerForm button {
width: 100%;
box-sizing: border-box;
flex: 1;
}
.spacer6 {
height: 6px;
}
@@ -452,6 +773,16 @@
border-radius: 10px;
}
.projectCardBodyOpening {
cursor: wait;
opacity: 0.72;
}
.projectCardBodyDisabled {
cursor: default;
opacity: 0.55;
}
.projectCardMenuBtn {
flex-shrink: 0;
margin: -4px -4px 0 0;
@@ -490,6 +821,19 @@
font-weight: 700;
}
.labelRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 28px;
}
.labelRow .labelSm {
flex: 1;
min-width: 0;
}
.spacer8 {
height: 8px;
}
@@ -505,6 +849,75 @@
outline: none;
}
.descriptionEmpty {
min-height: 36px;
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px dashed var(--stroke-2);
background: var(--color-overlay-dark-2);
color: var(--text2);
font-size: var(--text-xs);
line-height: 1.4;
display: flex;
align-items: center;
}
.descriptionPreview {
max-height: 4.6em;
overflow-x: hidden;
overflow-y: auto;
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
color: var(--text1);
font-size: var(--text-xs);
line-height: 1.45;
word-break: break-word;
}
.descriptionPreview :global(p),
.descriptionPreview :global(h2),
.descriptionPreview :global(h3),
.descriptionPreview :global(ul),
.descriptionPreview :global(ol),
.descriptionPreview :global(blockquote),
.descriptionPreview :global(pre) {
margin: 0 0 0.35em;
}
.descriptionPreview :global(p:last-child),
.descriptionPreview :global(h2:last-child),
.descriptionPreview :global(h3:last-child),
.descriptionPreview :global(ul:last-child),
.descriptionPreview :global(ol:last-child),
.descriptionPreview :global(blockquote:last-child),
.descriptionPreview :global(pre:last-child) {
margin-bottom: 0;
}
.descriptionPreview :global(h2),
.descriptionPreview :global(h3) {
font-size: 1em;
font-weight: 800;
color: var(--text0);
}
.descriptionPreview :global(ul),
.descriptionPreview :global(ol) {
padding-left: 1.2em;
}
.descriptionPreview :global(strong),
.descriptionPreview :global(b) {
font-weight: 800;
color: var(--text0);
}
.descriptionPreview :global(a) {
color: var(--accent2);
}
.hint {
color: var(--text2);
font-size: var(--text-xs);
@@ -522,6 +935,15 @@
align-items: center;
justify-content: center;
position: relative;
transition:
border-color 0.15s ease,
background 0.15s ease;
}
.previewBoxDragOver {
border-color: var(--accent);
border-style: dashed;
background: var(--accent-fill-soft);
}
.previewFill {
@@ -586,6 +1008,33 @@
flex-wrap: wrap;
}
.actionsRowHalf {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
width: 100%;
}
.actionsRowHalf > span {
display: flex;
min-width: 0;
width: 100%;
flex: 1 1 0;
}
.actionsRowHalf > span > button {
flex: 1 1 auto;
width: 100%;
}
.actionsRowVideoChecks {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
align-items: center;
}
.checkboxLabel {
display: flex;
gap: 8px;
@@ -615,6 +1064,47 @@
padding: 10px;
display: grid;
gap: 8px;
position: relative;
transition:
border-color 0.15s ease,
background 0.15s ease;
}
.audioDropEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 96px;
text-align: center;
padding: 8px 4px;
}
.audioDropEmpty > * {
max-width: 100%;
}
.audioDropDragOver {
border-color: var(--accent);
background: var(--accent-fill-soft);
}
.dropHintOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
border-radius: inherit;
background: var(--accent-fill-soft-2);
color: var(--accent);
font-size: var(--text-xs);
font-weight: 700;
text-align: center;
pointer-events: none;
z-index: 2;
}
.audioList {
@@ -687,6 +1177,7 @@
border: 1px solid transparent;
box-sizing: border-box;
background: transparent;
min-width: 0;
}
.sceneCard:not(.sceneCardActive):hover {
@@ -698,6 +1189,36 @@
background: var(--scene-list-selected-bg);
}
.sceneCardDragging {
opacity: 0.45;
}
.sceneCardDropBefore,
.sceneCardDropAfter {
position: relative;
}
.sceneCardDropBefore::before,
.sceneCardDropAfter::after {
content: '';
position: absolute;
left: 8px;
right: 8px;
height: 3px;
border-radius: 999px;
background: var(--accent);
z-index: 2;
pointer-events: none;
}
.sceneCardDropBefore::before {
top: 0;
}
.sceneCardDropAfter::after {
bottom: 0;
}
.sceneThumb {
height: 92px;
position: relative;
@@ -712,6 +1233,13 @@
overflow: hidden;
}
.sceneThumbInner img,
.sceneThumbInner video,
.sceneThumbVideo {
-webkit-user-drag: none;
user-select: none;
}
.sceneThumbVideo {
display: block;
width: 100%;
@@ -737,21 +1265,23 @@
padding: 10px;
display: grid;
gap: 6px;
min-width: 0;
}
.sceneCardHeader {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.badgeCurrent {
font-size: var(--text-xs);
color: var(--accent2);
flex-shrink: 0;
}
.sceneMenuBtn {
margin-left: auto;
border: none;
background: var(--panel2);
border-radius: var(--radius-xs);
@@ -765,6 +1295,11 @@
.sceneCardTitle {
font-weight: 750;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menuBackdrop {
@@ -802,3 +1337,77 @@
cursor: pointer;
width: 100%;
}
.modalDialogWide {
width: 640px;
}
.storylineChecklist {
display: grid;
gap: 8px;
max-height: 280px;
overflow-y: auto;
padding: 4px 0;
}
.storylineCheck {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-surface);
cursor: pointer;
font-size: 13px;
}
.storylineCheck input {
margin-top: 2px;
}
.storylineCheckDisabled {
opacity: 0.55;
cursor: not-allowed;
}
.conflictList {
display: grid;
gap: 12px;
max-height: 360px;
overflow-y: auto;
}
.conflictRow {
display: grid;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-surface);
}
.conflictRowTitle {
font-weight: 700;
font-size: 13px;
}
.conflictRowOptions {
display: grid;
gap: 6px;
}
.importFileRow {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.reportList {
display: grid;
gap: 6px;
font-size: 13px;
margin: 0;
padding-left: 18px;
}
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
export type FoundryImportSourceSelection =
| { kind: 'folder'; sourcePath: string }
| { kind: 'archive'; sourcePath: string };
type FoundryImportModalProps = {
open: boolean;
pickSource: (
mode: 'folder' | 'archive',
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
onClose: () => void;
onImport: (selection: FoundryImportSourceSelection) => Promise<void>;
};
export function FoundryImportModal({ open, pickSource, onClose, onImport }: FoundryImportModalProps) {
const { t } = useEditorI18n();
const [mode, setMode] = useState<'folder' | 'archive'>('folder');
const [picked, setPicked] = useState<{ path: string; name: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setMode('folder');
setPicked(null);
setSubmitting(false);
setError(null);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !submitting) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open, submitting]);
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!submitting) onClose();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('foundryImport.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!submitting) onClose();
}}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('foundryImport.hint')}</div>
<div className={styles.fieldLabel}>{t('foundryImport.sourceType')}</div>
<Select
value={mode}
disabled={submitting}
ariaLabel={t('foundryImport.sourceType')}
options={[
{ value: 'folder', label: t('foundryImport.folder') },
{ value: 'archive', label: t('foundryImport.archive') },
]}
onChange={(next) => {
setMode(next as 'folder' | 'archive');
setPicked(null);
setError(null);
}}
/>
<div className={styles.fieldLabel}>{t('foundryImport.source')}</div>
<div className={styles.importFileRow}>
<Button
disabled={submitting}
onClick={() => {
void (async () => {
setError(null);
const res = await pickSource(mode);
if (res.canceled) return;
const name = res.sourcePath.split(/[/\\]/).pop() ?? res.sourcePath;
setPicked({ path: res.sourcePath, name });
})();
}}
>
{mode === 'folder' ? t('foundryImport.chooseFolder') : t('foundryImport.chooseArchive')}
</Button>
<span className={styles.muted}>{picked ? picked.name : t('foundryImport.noSourceSelected')}</span>
</div>
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!picked || submitting}
onClick={() => {
if (!picked) return;
void (async () => {
setSubmitting(true);
setError(null);
try {
await onImport({
kind: mode,
sourcePath: picked.path,
});
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
})();
}}
>
{t('foundryImport.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}
@@ -0,0 +1,63 @@
.dialog {
width: min(440px, calc(100vw - 48px));
}
.hint {
margin: 0 0 12px;
color: var(--text2);
font-size: var(--text-sm);
line-height: 1.4;
}
.section {
display: grid;
gap: 8px;
margin-bottom: 14px;
}
.sectionTitle {
font-size: 11px;
font-weight: 700;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.list {
display: grid;
gap: 4px;
max-height: 220px;
overflow: auto;
padding: 8px;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: var(--color-overlay-dark-3);
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
color: var(--text);
font-size: var(--text-sm);
}
.row:hover {
background: var(--accent-fill-soft);
}
.teamDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
}
.empty {
padding: 10px 8px;
color: var(--text2);
font-size: var(--text-sm);
}
+165
View File
@@ -0,0 +1,165 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
import { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import launchStyles from './LaunchPlayersModal.module.css';
export { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
export function LaunchPlayersModal({
open,
onClose,
onConfirm,
}: {
open: boolean;
onClose: () => void;
onConfirm: (playerIds: string[]) => void;
}) {
const { t } = useEditorI18n();
const { players, teams } = useAppPlayers();
const [selectedPlayers, setSelectedPlayers] = useState<Set<string>>(() => new Set());
const [selectedTeams, setSelectedTeams] = useState<Set<string>>(() => new Set());
useEffect(() => {
if (!open) return;
setSelectedPlayers(new Set());
setSelectedTeams(new Set());
}, [open]);
const resolvedIds = useMemo(
() => resolveLaunchPlayerIds(players, selectedPlayers, selectedTeams),
[players, selectedPlayers, selectedTeams],
);
const standalonePlayers = useMemo(
() =>
players.filter((p) => !(p.teamId && selectedTeams.has(String(p.teamId)))),
[players, selectedTeams],
);
if (!open) return null;
const togglePlayer = (id: PlayerId) => {
setSelectedPlayers((prev) => {
const next = new Set(prev);
const key = String(id);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleTeam = (id: PlayerTeamId) => {
const key = String(id);
setSelectedTeams((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
// Участники выбранной команды не показываются в «Игроки» — снимаем их индивидуальный выбор.
setSelectedPlayers((prev) => {
if (prev.size === 0) return prev;
const next = new Set(prev);
let changed = false;
for (const p of players) {
if (String(p.teamId) !== key) continue;
if (next.delete(String(p.id))) changed = true;
}
return changed ? next : prev;
});
};
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div
className={[styles.modalDialog, launchStyles.dialog].join(' ')}
role="dialog"
aria-modal="true"
data-testid="launch-players-modal"
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('top.runWithPlayersTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div>
<p className={launchStyles.hint}>{t('top.runWithPlayersHint')}</p>
{teams.length > 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersTeams')}</div>
<div className={launchStyles.list}>
{teams.map((team: AppPlayerTeam) => {
const count = players.filter((p) => p.teamId === team.id).length;
return (
<label key={team.id} className={launchStyles.row}>
<input
type="checkbox"
checked={selectedTeams.has(String(team.id))}
onChange={() => toggleTeam(team.id)}
data-testid={`launch-team-${team.id}`}
/>
<span className={launchStyles.teamDot} style={{ background: team.color }} />
<span>
{team.name} ({count})
</span>
</label>
);
})}
</div>
</section>
) : null}
{standalonePlayers.length > 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersPlayers')}</div>
<div className={launchStyles.list}>
{standalonePlayers.map((player: AppPlayer) => (
<label key={player.id} className={launchStyles.row}>
<input
type="checkbox"
checked={selectedPlayers.has(String(player.id))}
onChange={() => togglePlayer(player.id)}
data-testid={`launch-player-${player.id}`}
/>
<span>{player.name}</span>
</label>
))}
</div>
</section>
) : players.length === 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.empty}>{t('top.runWithPlayersEmpty')}</div>
</section>
) : null}
</div>
<div className={styles.modalFooter}>
<Button variant="ghost" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={resolvedIds.length === 0}
data-testid="launch-players-confirm"
onClick={() => onConfirm(resolvedIds)}
>
{t('top.runWithPlayersConfirm')}
</Button>
</div>
</div>
</>,
document.body,
);
}
@@ -0,0 +1,148 @@
.legendBlock {
display: grid;
gap: 10px;
min-width: 0;
}
.legendBlockLarge {
gap: 12px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.legendHeadRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
.legendHeadRow .row {
margin: 0;
}
.hint {
font-size: 12px;
opacity: 0.7;
line-height: 1.35;
}
.map {
position: relative;
width: 100%;
max-height: 280px;
overflow: hidden;
border-radius: 10px;
border: 1px solid var(--stroke, #333);
background: #0a0b0e;
}
.mapLarge {
max-height: none;
height: min(48vh, 440px);
min-height: 260px;
flex: 0 0 auto;
touch-action: none;
cursor: grab;
}
.mapIdle {
display: grid;
place-items: center;
}
.mapEmpty {
color: var(--text2);
font-size: 13px;
}
.mapImg {
display: block;
width: 100%;
height: 100%;
max-height: inherit;
object-fit: contain;
user-select: none;
pointer-events: none;
margin: 0 auto;
}
.marker {
position: absolute;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
border-radius: 50%;
background: #1e3a5f;
border: 2px solid #f5c542;
color: #fff;
font-size: 11px;
font-weight: 800;
display: grid;
place-items: center;
cursor: grab;
user-select: none;
z-index: 2;
touch-action: none;
}
.mapHint {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 3;
font-size: 12px;
padding: 6px 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.65);
color: rgba(255, 255, 255, 0.85);
pointer-events: none;
}
.mapHintZoom {
position: absolute;
right: 12px;
bottom: 12px;
z-index: 3;
font-size: 11px;
padding: 5px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.55);
color: rgba(255, 255, 255, 0.75);
pointer-events: none;
}
.itemRow {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 8px;
align-items: center;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
}
.itemRowActive {
border-color: color-mix(in srgb, var(--color-accent, #c9a227) 70%, transparent);
background: color-mix(in srgb, var(--color-accent, #c9a227) 12%, transparent);
}
.num {
font-weight: 800;
text-align: center;
}
.itemDrag {
cursor: pointer;
opacity: 0.7;
font-size: 12px;
border: none;
background: transparent;
color: inherit;
}
@@ -0,0 +1,483 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { MaterialLegend, MaterialLegendItem, MaterialLegendMarker } from '../../shared/types';
import {
asMaterialLegendItemId,
asMaterialLegendMarkerId,
EMPTY_MATERIAL_LEGEND,
hostPointToLegendImageUv,
legendImageUvToHostPoint,
nextLegendNumber,
} from '../../shared/types/materialLegend';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
sceneViewZoomAt,
type SceneViewCamera,
} from '../../shared/types/sceneView';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
import styles from './MaterialLegendEditor.module.css';
type Props = {
legend: MaterialLegend | undefined;
previewUrl: string | null;
rotationDeg?: 0 | 90 | 180 | 270;
/** Крупная карта (окно «Материалы»). */
largeMap?: boolean;
onChange: (legend: MaterialLegend) => void;
onRotate?: () => void;
rotateLabel?: string;
};
type DragMode =
| { kind: 'pan'; lastX: number; lastY: number }
| { kind: 'marker'; id: string }
| null;
const PERSIST_DEBOUNCE_MS = 180;
function rid(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function cloneLegend(legend: MaterialLegend | undefined): MaterialLegend {
const base = legend ?? EMPTY_MATERIAL_LEGEND;
return {
enabled: base.enabled,
items: base.items.map((it) => ({ ...it })),
markers: base.markers.map((m) => ({ ...m })),
};
}
export function MaterialLegendEditor({
legend,
previewUrl,
rotationDeg = 0,
largeMap = false,
onChange,
onRotate,
rotateLabel = 'Повернуть',
}: Props) {
const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend));
const [activeItemId, setActiveItemId] = useState<string | null>(null);
const [view, setView] = useState<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
null,
);
const mapRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null);
const skipMapClickRef = useRef(false);
const spaceDownRef = useRef(false);
const draftRef = useRef(draft);
draftRef.current = draft;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const saveTimerRef = useRef(0);
const dirtyRef = useRef(false);
const flushPersist = () => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = 0;
}
if (!dirtyRef.current) return;
dirtyRef.current = false;
onChangeRef.current(draftRef.current);
};
const schedulePersist = () => {
dirtyRef.current = true;
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = 0;
dirtyRef.current = false;
onChangeRef.current(draftRef.current);
}, PERSIST_DEBOUNCE_MS);
};
const applyDraft = (next: MaterialLegend) => {
draftRef.current = next;
setDraft(next);
schedulePersist();
};
useEffect(() => {
return () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
if (dirtyRef.current) onChangeRef.current(draftRef.current);
};
}, []);
useEffect(() => {
flushPersist();
setDraft(cloneLegend(legend));
dirtyRef.current = false;
setActiveItemId(null);
setView(DEFAULT_SCENE_VIEW_CAMERA);
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when switching material image
}, [previewUrl]);
useEffect(() => {
if (activeItemId && !draft.items.some((i) => i.id === activeItemId)) {
setActiveItemId(null);
}
}, [activeItemId, draft.items]);
useEffect(() => {
if (!largeMap) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = true;
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [largeMap]);
useEffect(() => {
if (!largeMap) return;
const host = mapRef.current;
if (!host) return;
const nativeWheel = (e: WheelEvent) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const cr = contentRect;
if (!cr) {
setView((v) => {
const nextScale = Math.max(1, Math.min(8, v.scale * factor));
if (nextScale <= 1.001) return { ...DEFAULT_SCENE_VIEW_CAMERA };
return { ...v, scale: nextScale };
});
return;
}
const r = host.getBoundingClientRect();
setView((v) => {
const containW = cr.w / Math.max(1e-6, v.scale);
const containH = cr.h / Math.max(1e-6, v.scale);
return sceneViewZoomAt(v, {
hostW: r.width,
hostH: r.height,
containW,
containH,
hostX: e.clientX - r.left,
hostY: e.clientY - r.top,
factor,
});
});
};
host.addEventListener('wheel', nativeWheel, { passive: false });
return () => host.removeEventListener('wheel', nativeWheel);
}, [contentRect, largeMap, previewUrl]);
const activeItem = useMemo(
() => (activeItemId ? (draft.items.find((i) => i.id === activeItemId) ?? null) : null),
[activeItemId, draft.items],
);
const setEnabled = (enabled: boolean) => {
applyDraft({ ...draftRef.current, enabled });
};
const updateItems = (items: MaterialLegendItem[]) => {
applyDraft({ ...draftRef.current, enabled: true, items });
};
const updateMarkers = (markers: MaterialLegendMarker[]) => {
applyDraft({ ...draftRef.current, enabled: true, markers });
};
const addItem = () => {
const cur = draftRef.current;
const number = nextLegendNumber(cur.items);
const item: MaterialLegendItem = {
id: asMaterialLegendItemId(rid('li')),
number,
text: '',
};
updateItems([...cur.items, item]);
setActiveItemId(item.id);
};
const placeMarker = (nx: number, ny: number, number: number) => {
updateMarkers([
...draftRef.current.markers,
{
id: asMaterialLegendMarkerId(rid('lm')),
number,
nx,
ny,
},
]);
};
const toNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
const el = mapRef.current;
if (!el) return null;
const r = el.getBoundingClientRect();
// UV неповёрнутого изображения — тот же space, что у MaterialOverlay на пульте/презентации.
if (largeMap && contentRect && contentRect.w > 1 && contentRect.h > 1) {
return hostPointToLegendImageUv(
clientX - r.left,
clientY - r.top,
contentRect,
rotationDeg,
);
}
const img = el.querySelector('img');
if (!img) return null;
const ir = img.getBoundingClientRect();
if (ir.width < 1 || ir.height < 1) return null;
return {
x: Math.max(0, Math.min(1, (clientX - ir.left) / ir.width)),
y: Math.max(0, Math.min(1, (clientY - ir.top) / ir.height)),
};
};
const onMapPointerDown = (e: React.PointerEvent) => {
if (!largeMap) return;
if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
skipMapClickRef.current = true;
}
};
const onMapPointerMove = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
if (d.kind === 'pan') {
const cr = contentRect;
if (!cr) return;
const dx = e.clientX - d.lastX;
const dy = e.clientY - d.lastY;
d.lastX = e.clientX;
d.lastY = e.clientY;
setView((v) => {
const containW = cr.w / Math.max(1e-6, v.scale);
const containH = cr.h / Math.max(1e-6, v.scale);
return sceneViewPanBy(v, { containW, containH, dx, dy });
});
return;
}
if (d.kind === 'marker') {
skipMapClickRef.current = true;
const p = toNorm(e.clientX, e.clientY);
if (!p) return;
const nextMarkers = draftRef.current.markers.map((x) =>
x.id === d.id ? { ...x, nx: p.x, ny: p.y } : x,
);
const next = { ...draftRef.current, enabled: true, markers: nextMarkers };
draftRef.current = next;
setDraft(next);
// persist только на pointerup
}
};
const onMapPointerUp = () => {
const d = dragRef.current;
if (d?.kind === 'marker') {
schedulePersist();
}
dragRef.current = null;
};
const onMapClick = (e: React.MouseEvent) => {
if (skipMapClickRef.current) {
skipMapClickRef.current = false;
return;
}
if (dragRef.current) return;
if (!draft.enabled || !activeItem) return;
const p = toNorm(e.clientX, e.clientY);
if (!p) return;
placeMarker(p.x, p.y, activeItem.number);
};
const renderMarkers = () => {
if (!draft.enabled) return null;
return draft.markers.map((m) => {
const style =
largeMap && contentRect
? (() => {
const p = legendImageUvToHostPoint(m.nx, m.ny, contentRect, rotationDeg);
return { left: p.x, top: p.y };
})()
: {
left: `${String(m.nx * 100)}%`,
top: `${String(m.ny * 100)}%`,
};
return (
<div
key={m.id}
className={styles.marker}
style={style}
onPointerDown={(e) => {
if (!draft.enabled) return;
e.stopPropagation();
e.preventDefault();
dragRef.current = { kind: 'marker', id: m.id };
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
}}
onPointerMove={onMapPointerMove}
onPointerUp={(e) => {
e.stopPropagation();
if (dragRef.current?.kind === 'marker') skipMapClickRef.current = true;
onMapPointerUp();
}}
onClick={(e) => {
e.stopPropagation();
skipMapClickRef.current = true;
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
updateMarkers(draftRef.current.markers.filter((x) => x.id !== m.id));
}}
title="Перетащите, чтобы переместить. ПКМ — удалить"
>
{m.number}
</div>
);
});
};
const mapBlock = previewUrl ? (
<div
ref={mapRef}
className={[
styles.map,
largeMap ? styles.mapLarge : '',
!draft.enabled ? styles.mapIdle : '',
]
.filter(Boolean)
.join(' ')}
onDragOver={(e) => {
if (!draft.enabled) return;
e.preventDefault();
}}
onDrop={(e) => {
if (!draft.enabled) return;
e.preventDefault();
const num = Number(e.dataTransfer.getData('application/x-legend-number'));
const p = toNorm(e.clientX, e.clientY);
if (!p || !Number.isFinite(num) || num < 1) return;
placeMarker(p.x, p.y, Math.round(num));
}}
onClick={onMapClick}
onPointerDown={onMapPointerDown}
onPointerMove={onMapPointerMove}
onPointerUp={onMapPointerUp}
onPointerCancel={onMapPointerUp}
>
{largeMap ? (
<RotatedImage
url={previewUrl}
rotationDeg={rotationDeg}
mode="contain"
viewCamera={view}
onContentRectChange={setContentRect}
/>
) : (
<img className={styles.mapImg} src={previewUrl} alt="" draggable={false} />
)}
{renderMarkers()}
{draft.enabled && !activeItem ? (
<div className={styles.mapHint}>Выберите строку легенды, чтобы ставить метки</div>
) : null}
{largeMap ? (
<div className={styles.mapHintZoom}>Колесо зум · СКМ / Space+ЛКМ пан</div>
) : null}
</div>
) : (
<div className={[styles.map, largeMap ? styles.mapLarge : '', styles.mapIdle].filter(Boolean).join(' ')}>
<div className={styles.mapEmpty}>Нет изображения</div>
</div>
);
return (
<div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}>
{largeMap ? mapBlock : null}
<div className={styles.legendHeadRow}>
{onRotate ? (
<Button onClick={onRotate}>{rotateLabel}</Button>
) : null}
<label className={styles.row}>
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
<span>Легенда</span>
</label>
</div>
{draft.enabled ? (
<>
<div className={styles.row}>
<Button onClick={addItem}>Добавить пункт</Button>
<span className={styles.hint}>
Активная строка подсвечена клик по картинке ставит метку; клик по метке перемещение
</span>
</div>
{draft.items.map((item) => {
const active = item.id === activeItemId;
return (
<div
key={item.id}
className={[styles.itemRow, active ? styles.itemRowActive : ''].filter(Boolean).join(' ')}
draggable
onClick={() => setActiveItemId(item.id)}
onDragStart={(e) => {
e.dataTransfer.setData('application/x-legend-number', String(item.number));
setActiveItemId(item.id);
}}
>
<div className={styles.num}>{item.number}</div>
<Input
value={item.text}
onChange={(text) => {
updateItems(
draftRef.current.items.map((it) => (it.id === item.id ? { ...it, text } : it)),
);
}}
placeholder="Описание…"
/>
<button
type="button"
className={styles.itemDrag}
title="Удалить"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const cur = draftRef.current;
applyDraft({
...cur,
enabled: true,
items: cur.items.filter((it) => it.id !== item.id),
markers: cur.markers.filter((m) => m.number !== item.number),
});
if (activeItemId === item.id) setActiveItemId(null);
}}
>
×
</button>
</div>
);
})}
{!largeMap ? mapBlock : null}
</>
) : largeMap ? null : (
mapBlock
)}
</div>
);
}
+423
View File
@@ -0,0 +1,423 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { MaterialLegendEditor } from './MaterialLegendEditor';
import matStyles from './MaterialsModals.module.css';
const DND_MATERIAL_ID_MIME = 'application/x-dnd-material-id';
export type MaterialsBrowserProps = {
materials: ProjectMaterial[];
/** editor: CRUD + ⋮; runtime: показ на сцене, без меню */
mode: 'editor' | 'runtime';
selectedId: MaterialId | null;
onSelect: (id: MaterialId | null) => void;
activeMaterialIds?: readonly MaterialId[];
onAdd?: () => void;
onEdit?: (material: ProjectMaterial) => void;
onDelete?: (materialId: MaterialId) => Promise<void>;
onReorder?: (materialIds: MaterialId[]) => Promise<void>;
onRotate?: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<void>;
onTileActivate?: (materialId: MaterialId) => void;
toolbar?: React.ReactNode;
className?: string | undefined;
/** Растянуть тело на всю высоту (окно Electron). */
fillHeight?: boolean;
/** Только колонка списка (без большого превью). */
listOnly?: boolean;
};
export function MaterialsBrowser({
materials,
mode,
selectedId,
onSelect,
activeMaterialIds = [],
onAdd,
onEdit,
onDelete,
onReorder,
onRotate,
onLegendChange,
onTileActivate,
toolbar,
className,
fillHeight = false,
listOnly = false,
}: MaterialsBrowserProps) {
const { t } = useEditorI18n();
const [query, setQuery] = useState('');
const [menuFor, setMenuFor] = useState<MaterialId | null>(null);
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const [dragId, setDragId] = useState<MaterialId | null>(null);
const [dropPlace, setDropPlace] = useState<{ id: MaterialId; place: 'before' | 'after' } | null>(null);
const [pendingDelete, setPendingDelete] = useState<ProjectMaterial | null>(null);
useEffect(() => {
if (!menuFor) return;
const onDown = (e: MouseEvent) => {
const tgt = e.target as HTMLElement | null;
if (!tgt) return;
if (tgt.closest('[data-material-menu-root="1"]')) return;
setMenuFor(null);
setMenuPos(null);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [menuFor]);
const activeSet = useMemo(() => new Set(activeMaterialIds), [activeMaterialIds]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return materials;
return materials.filter((m) => m.name.toLowerCase().includes(q));
}, [materials, query]);
useEffect(() => {
if (selectedId && filtered.some((m) => m.id === selectedId)) return;
const next = filtered[0]?.id ?? null;
if (next !== selectedId) onSelect(next);
// eslint-disable-next-line react-hooks/exhaustive-deps -- sync selection to filtered list
}, [filtered, selectedId]);
const selected = materials.find((m) => m.id === selectedId) ?? null;
const selectedUrl = useAssetUrl(selected?.assetId ?? null);
return (
<div
className={[
matStyles.browserRoot,
fillHeight ? matStyles.browserRootFill : '',
listOnly ? matStyles.browserRootListOnly : '',
className,
]
.filter(Boolean)
.join(' ')}
>
{toolbar ? <div className={matStyles.browserToolbar}>{toolbar}</div> : null}
<div
className={[
matStyles.managerBody,
fillHeight ? matStyles.managerBodyFill : '',
listOnly ? matStyles.managerBodyListOnly : '',
]
.filter(Boolean)
.join(' ')}
>
<div className={matStyles.side}>
<Input value={query} onChange={setQuery} placeholder={t('materials.search')} />
{mode === 'editor' && onAdd ? (
<div className={matStyles.sideAddBtn}>
<Button variant="primary" onClick={onAdd}>
{t('materials.add')}
</Button>
</div>
) : null}
<div className={matStyles.list}>
{filtered.map((m) => (
<MaterialTile
key={m.id}
material={m}
selected={m.id === selectedId}
active={activeSet.has(m.id)}
showMenu={mode === 'editor'}
dragging={dragId === m.id}
dropPlace={dropPlace?.id === m.id ? dropPlace.place : null}
reorderEnabled={mode === 'editor' && Boolean(onReorder)}
onSelect={() => {
onSelect(m.id);
if (mode === 'runtime' && onTileActivate) onTileActivate(m.id);
}}
onMenu={(e) => {
if (mode !== 'editor') return;
const r = e.currentTarget.getBoundingClientRect();
const menuW = 180;
const menuH = 88;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
const top =
r.bottom + 8 + menuH > window.innerHeight - 8
? Math.max(8, r.top - menuH - 8)
: r.bottom + 8;
setMenuPos({ left, top });
setMenuFor((cur) => (cur === m.id ? null : m.id));
}}
onDragStart={() => setDragId(m.id)}
onDragEnd={() => {
setDragId(null);
setDropPlace(null);
}}
onDragOver={(place) => {
if (!dragId || dragId === m.id) {
setDropPlace(null);
return;
}
setDropPlace({ id: m.id, place });
}}
onDropReorder={async () => {
if (!onReorder || !dragId || !dropPlace || dragId === dropPlace.id) return;
const ids = materials.map((x) => x.id);
const from = ids.indexOf(dragId);
if (from < 0) return;
ids.splice(from, 1);
let to = ids.indexOf(dropPlace.id);
if (to < 0) return;
if (dropPlace.place === 'after') to += 1;
ids.splice(to, 0, dragId);
setDragId(null);
setDropPlace(null);
await onReorder(ids);
}}
/>
))}
{materials.length === 0 ? <div className={styles.muted}>{t('materials.empty')}</div> : null}
{materials.length > 0 && filtered.length === 0 ? (
<div className={styles.muted}>{t('materials.searchEmpty')}</div>
) : null}
</div>
</div>
{!listOnly ? (
<div
className={[
matStyles.previewColumn,
onLegendChange ? matStyles.previewColumnLegend : '',
]
.filter(Boolean)
.join(' ')}
>
{selected && selectedUrl && onLegendChange ? (
<div className={matStyles.previewLegendScroll}>
<MaterialLegendEditor
key={selected.id}
largeMap
legend={selected.legend}
previewUrl={selectedUrl}
rotationDeg={selected.rotationDeg ?? 0}
rotateLabel={t('scene.rotate')}
onRotate={
onRotate
? () => {
const cur = selected.rotationDeg ?? 0;
const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270;
onRotate(selected.id, next);
}
: undefined
}
onChange={(next) => {
void onLegendChange(selected.id, next);
}}
/>
</div>
) : (
<div className={matStyles.previewPane}>
{selected && selectedUrl ? (
<div className={matStyles.previewLargeHost}>
<RotatedImage
url={selectedUrl}
rotationDeg={selected.rotationDeg ?? 0}
mode="contain"
/>
</div>
) : (
<div className={matStyles.previewEmpty}>{t('materials.addPrompt')}</div>
)}
</div>
)}
{selected && onRotate && !onLegendChange ? (
<div className={matStyles.previewActions}>
<Button
onClick={() => {
const cur = selected.rotationDeg ?? 0;
const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270;
onRotate(selected.id, next);
}}
>
{t('scene.rotate')}
</Button>
</div>
) : null}
</div>
) : null}
</div>
{mode === 'editor' && menuFor && menuPos
? createPortal(
<div
role="menu"
data-material-menu-root="1"
className={styles.fileMenu}
style={{ left: menuPos.left, top: menuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
const mat = materials.find((x) => x.id === menuFor);
setMenuFor(null);
setMenuPos(null);
if (mat && onEdit) onEdit(mat);
}}
>
{t('materials.edit')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItemDanger}
onClick={() => {
const mat = materials.find((x) => x.id === menuFor);
setMenuFor(null);
setMenuPos(null);
if (mat) setPendingDelete(mat);
}}
>
{t('common.delete')}
</button>
</div>,
document.body,
)
: null}
{pendingDelete
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDelete(null)}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('materials.deleteTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDelete(null)}
>
×
</button>
</div>
<div className={styles.muted}>
{t('materials.deleteConfirm', { name: pendingDelete.name })}
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDelete(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDelete.id;
setPendingDelete(null);
if (onDelete) void onDelete(id);
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div>
);
}
function MaterialTile({
material,
selected,
active,
showMenu,
dragging,
dropPlace,
reorderEnabled,
onSelect,
onMenu,
onDragStart,
onDragEnd,
onDragOver,
onDropReorder,
}: {
material: ProjectMaterial;
selected: boolean;
active: boolean;
showMenu: boolean;
dragging: boolean;
dropPlace: 'before' | 'after' | null;
reorderEnabled: boolean;
onSelect: () => void;
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragOver: (place: 'before' | 'after') => void;
onDropReorder: () => void;
}) {
const { t } = useEditorI18n();
const url = useAssetUrl(material.assetId);
return (
<div
className={[
matStyles.tile,
selected ? matStyles.tileSelected : '',
active ? matStyles.tileActive : '',
dragging ? matStyles.tileDragging : '',
dropPlace === 'before' ? matStyles.tileDropBefore : '',
dropPlace === 'after' ? matStyles.tileDropAfter : '',
]
.filter(Boolean)
.join(' ')}
draggable={reorderEnabled}
onDragStart={(e) => {
if (!reorderEnabled) return;
e.dataTransfer.setData(DND_MATERIAL_ID_MIME, material.id);
e.dataTransfer.effectAllowed = 'move';
onDragStart();
}}
onDragEnd={onDragEnd}
onDragOver={(e) => {
if (!reorderEnabled) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const place = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
onDragOver(place);
}}
onDrop={(e) => {
if (!reorderEnabled) return;
e.preventDefault();
onDropReorder();
}}
>
<button type="button" className={matStyles.tileBody} onClick={onSelect}>
{url ? (
<div className={matStyles.tileImg}>
<RotatedImage url={url} rotationDeg={material.rotationDeg ?? 0} mode="contain" />
</div>
) : (
<div className={matStyles.tileImgEmpty} />
)}
<div className={matStyles.tileName}>{material.name}</div>
</button>
{showMenu ? (
<button
type="button"
className={matStyles.tileMenu}
data-material-menu-root="1"
aria-label={t('materials.tileMenu')}
onClick={onMenu}
>
</button>
) : null}
</div>
);
}
@@ -0,0 +1,313 @@
.managerDialog {
width: min(1100px, calc(100vw - 48px));
max-width: 1100px;
}
.browserRoot {
display: grid;
gap: 10px;
min-height: 0;
}
.browserRootFill {
height: 100%;
grid-template-rows: auto 1fr;
}
.browserRootListOnly {
grid-template-rows: auto 1fr;
}
.browserToolbar {
display: grid;
gap: 8px;
min-width: 0;
}
.browserToolbarRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.browserToolbarRow > * {
width: 100%;
min-width: 0;
display: flex;
flex: 1 1 0;
}
.browserToolbarRow > * > button {
flex: 1 1 auto;
width: 100%;
min-width: 0;
}
.browserToolbarZoomRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.browserToolbarFullBtn {
width: 100%;
}
.browserToolbarFullBtn > button {
width: 100%;
}
.browserToolbarHint {
color: var(--text2);
font-size: 11px;
line-height: 1.35;
padding: 0 2px;
word-break: break-word;
}
.managerBody {
display: grid;
grid-template-columns: 240px 1fr;
gap: 14px;
/* ~3 плитки по высоте + поиск/кнопка; дальше скролл в списке */
min-height: 560px;
max-height: min(84vh, 820px);
}
.managerBodyFill {
max-height: none;
height: 100%;
min-height: 0;
}
.managerBodyListOnly {
grid-template-columns: 1fr;
min-height: 0;
max-height: none;
height: 100%;
}
.side {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
height: 100%;
overflow: hidden;
}
.sideAddBtn {
width: 100%;
flex: 0 0 auto;
}
.sideAddBtn > * {
width: 100%;
max-width: 100%;
display: flex;
box-sizing: border-box;
}
.sideAddBtn button {
width: 100%;
box-sizing: border-box;
flex: 1;
}
.list {
display: grid;
gap: 8px;
align-content: start;
overflow: auto;
flex: 1 1 auto;
min-height: 0;
padding-right: 2px;
}
.tile {
display: flex;
align-items: stretch;
gap: 4px;
border: 1px solid var(--stroke);
border-radius: 12px;
background: var(--color-overlay-dark-2);
position: relative;
}
.tileSelected {
border-color: var(--color-accent, #a78bfa);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent, #a78bfa) 45%, transparent);
}
.tileActive {
outline: 1px solid color-mix(in srgb, var(--color-accent, #c9a227) 70%, transparent);
}
.tileDragging {
opacity: 0.55;
}
.tileDropBefore::before,
.tileDropAfter::after {
content: '';
position: absolute;
left: 8px;
right: 8px;
height: 2px;
background: var(--color-accent, #a78bfa);
z-index: 2;
}
.tileDropBefore::before {
top: -1px;
}
.tileDropAfter::after {
bottom: -1px;
}
.tileBody {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: inherit;
text-align: left;
padding: 8px;
cursor: pointer;
display: grid;
gap: 6px;
font: inherit;
}
.tileImg,
.tileImgEmpty {
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 8px;
background: var(--color-overlay-dark-4);
overflow: hidden;
position: relative;
}
.tileName {
font-size: var(--text-xs);
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tileMenu {
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
padding: 8px 10px;
font-size: 18px;
line-height: 1;
align-self: start;
}
.tileMenu:hover {
color: var(--text0);
}
.previewColumn {
display: grid;
grid-template-rows: 1fr auto;
gap: 10px;
min-height: 0;
}
.previewColumnLegend {
grid-template-rows: minmax(0, 1fr) auto;
min-height: 0;
}
.previewLegendScroll {
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.previewPane {
width: 100%;
/* высота ≈ 3 плитки списка */
height: 520px;
min-height: 520px;
max-height: 520px;
border: 1px solid var(--stroke);
border-radius: 14px;
background: var(--color-overlay-dark-3);
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.managerBodyFill .previewPane {
height: 100%;
min-height: 0;
max-height: none;
}
.previewLargeHost {
position: absolute;
inset: 16px;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.previewEmpty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--text2);
font-size: var(--text-sm);
padding: 24px;
text-align: center;
}
.previewActions {
display: flex;
gap: 8px;
}
.imageDrop {
display: grid;
gap: 10px;
padding: 12px;
border-radius: var(--radius-md);
border: 1px dashed var(--stroke-2);
background: var(--color-overlay-dark-2);
position: relative;
}
.imageDropEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 112px;
text-align: center;
padding: 8px 4px;
}
.imageDropEmpty > * {
max-width: 100%;
}
.imageDropOver {
border-color: var(--color-accent, #a78bfa);
}
.previewThumb {
width: 100%;
max-height: 160px;
object-fit: contain;
border-radius: 8px;
background: var(--color-overlay-dark-4);
}
+347
View File
@@ -0,0 +1,347 @@
import React, { useCallback, useEffect, useState } from 'react';
import { createPortal, flushSync } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
import { getDndApi } from '../shared/dndApi';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './EditorApp.module.css';
import {
filterMaterialImagePaths,
getDroppedFileEntries,
pickFirstMaterialImagePath,
useFileDropZone,
} from './fileDrop';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { MaterialsBrowser } from './MaterialsBrowser';
import matStyles from './MaterialsModals.module.css';
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
type MaterialEditModalProps = {
open: boolean;
initial: ProjectMaterial | null;
existingNames: string[];
onClose: () => void;
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
onSave: (input: { name: string; filePath?: string }) => Promise<void>;
};
export function MaterialEditModal({
open,
initial,
existingNames,
onClose,
onPickImage,
onSave,
}: MaterialEditModalProps) {
const { t } = useEditorI18n();
const api = getDndApi();
const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const existingUrl = useAssetUrl(initial?.assetId ?? null);
useEffect(() => {
if (!open) return;
setName(initial?.name ?? '');
setFilePath(null);
setLocalPreviewUrl(null);
setSaving(false);
setSaveProgress(null);
setError(null);
}, [initial, open]);
useEffect(() => {
return () => {
if (localPreviewUrl?.startsWith('blob:')) URL.revokeObjectURL(localPreviewUrl);
};
}, [localPreviewUrl]);
useEffect(() => {
if (!open) return;
return api.on(ipcChannels.project.materialUpsertProgress, (evt) => {
setSaveProgress({
percent: Math.max(0, Math.min(100, Math.round(evt.percent))),
detail: evt.detail?.trim() || t('materials.savingWait'),
});
});
}, [api, open, t]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !saving) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open, saving]);
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
setFilePath(path);
setLocalPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return previewUrl;
});
};
const drop = useFileDropZone({
onDropPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
if (!picked) return;
setPreviewFromPathAndUrl(picked, '');
},
filterPaths: filterMaterialImagePaths,
});
const trimmed = name.trim();
const nameOk = trimmed.length >= 1;
const nameDup =
nameOk &&
existingNames.some(
(n) => normalizeName(n) === normalizeName(trimmed) && normalizeName(n) !== normalizeName(initial?.name ?? ''),
);
const hasImage = Boolean(filePath) || Boolean(initial?.assetId);
const canSave = nameOk && !nameDup && hasImage && !saving;
const previewSrc = localPreviewUrl || existingUrl;
if (!open) return null;
const progressPercent = saveProgress?.percent ?? (saving ? 0 : 0);
const progressDetail = saveProgress?.detail ?? t('materials.savingWait');
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!saving) onClose();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>
{initial ? t('materials.editTitle') : t('materials.addTitle')}
</div>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!saving) onClose();
}}
className={styles.modalClose}
disabled={saving}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('materials.name')}</div>
<Input value={name} onChange={setName} placeholder={t('materials.namePlaceholder')} />
{!nameOk ? <div className={styles.fieldError}>{t('materials.nameRequired')}</div> : null}
{nameDup ? <div className={styles.fieldError}>{t('materials.nameDup')}</div> : null}
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('materials.image')}</div>
<div
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
onDragEnter={drop.onDragEnter}
onDragLeave={drop.onDragLeave}
onDragOver={drop.onDragOver}
onDrop={(e) => {
drop.onDrop(e);
const entries = getDroppedFileEntries(e);
const files = e.dataTransfer?.files;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i]!;
if (!pickFirstMaterialImagePath([entry.path])) continue;
const file = files?.[i];
if (file) {
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
return;
}
setPreviewFromPathAndUrl(entry.path, '');
return;
}
}}
>
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('materials.dropHint')}</div> : null}
{previewSrc ? (
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
) : (
<div className={matStyles.imageDropEmpty}>
<div className={styles.muted}>{t('materials.imageEmpty')}</div>
<Button
disabled={saving}
onClick={() => {
void (async () => {
const picked = await onPickImage();
if (!picked) return;
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
})();
}}
>
{t('materials.chooseImage')}
</Button>
</div>
)}
{previewSrc ? (
<Button
disabled={saving}
onClick={() => {
void (async () => {
const picked = await onPickImage();
if (!picked) return;
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
})();
}}
>
{t('materials.chooseImage')}
</Button>
) : null}
</div>
{!hasImage ? <div className={styles.fieldError}>{t('materials.imageRequired')}</div> : null}
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canSave}
onClick={() => {
if (!canSave) return;
void (async () => {
flushSync(() => {
setSaving(true);
setSaveProgress({ percent: 0, detail: t('materials.savingWait') });
setError(null);
});
try {
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
setSaveProgress(null);
}
})();
}}
>
{saving ? t('common.saving') : t('common.save')}
</Button>
</div>
</div>
{saving ? (
<div
className={styles.progressOverlay}
role="dialog"
aria-label={t('materials.savingProgress')}
aria-busy
>
<div className={styles.progressModal}>
<div className={styles.progressTitle}>{t('materials.savingTitle')}</div>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${String(Math.max(0, Math.min(100, progressPercent)))}%` }}
/>
</div>
<div className={styles.progressMeta}>
<div>{progressDetail}</div>
<div>{progressPercent}%</div>
</div>
</div>
</div>
) : null}
</>,
document.body,
);
}
type MaterialsManagerModalProps = {
open: boolean;
materials: ProjectMaterial[];
onClose: () => void;
onAdd: () => void;
onEdit: (material: ProjectMaterial) => void;
onDelete: (materialId: MaterialId) => Promise<void>;
onReorder: (materialIds: MaterialId[]) => Promise<void>;
onRotate: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<void>;
};
export function MaterialsManagerModal({
open,
materials,
onClose,
onAdd,
onEdit,
onDelete,
onReorder,
onRotate,
onLegendChange,
}: MaterialsManagerModalProps) {
const { t } = useEditorI18n();
const [selectedId, setSelectedId] = useState<MaterialId | null>(null);
const onSelect = useCallback((id: MaterialId | null) => setSelectedId(id), []);
useEffect(() => {
if (!open) return;
setSelectedId(materials[0]?.id ?? null);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<div role="dialog" aria-modal="true" className={[styles.modalDialog, matStyles.managerDialog].join(' ')}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('materials.managerTitle')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<MaterialsBrowser
mode="editor"
materials={materials}
selectedId={selectedId}
onSelect={onSelect}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
onReorder={onReorder}
onRotate={onRotate}
{...(onLegendChange ? { onLegendChange } : {})}
/>
</div>
</>,
document.body,
);
}
@@ -0,0 +1,243 @@
.dialog {
width: min(1080px, calc(100vw - 48px));
max-width: 1080px;
}
.body {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
gap: 16px;
min-height: 560px;
max-height: min(78vh, 760px);
}
.sidebar,
.editor {
min-height: 0;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebarActions {
display: flex;
gap: 8px;
}
.sidebarActions > * {
flex: 1 1 0;
min-width: 0;
}
.sidebarActions button {
width: 100%;
}
.teamForm {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid var(--stroke);
border-radius: 10px;
background: var(--color-overlay-dark-3);
}
.teamFormField {
display: grid;
gap: 4px;
color: var(--text2);
font-size: 11px;
font-weight: 700;
}
.teamFormField input[type='color'] {
width: 100%;
height: 34px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: 8px;
background: transparent;
}
.teamFormActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.teamFormActions > * {
flex: 1 1 0;
min-width: 0;
}
.teamFormActions button {
width: 100%;
}
.list {
display: grid;
gap: 10px;
overflow: auto;
padding-right: 3px;
}
.team {
border: 1px solid var(--stroke);
border-radius: 10px;
background: var(--color-overlay-dark-2);
overflow: visible;
}
.teamHeader {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
min-height: 34px;
padding: 4px 8px;
border-bottom: 1px solid var(--stroke);
}
.teamDot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.teamName {
overflow: hidden;
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.teamMenu {
position: relative;
}
.teamMenu summary {
cursor: pointer;
list-style: none;
padding: 4px 7px;
}
.teamMenuPopup {
position: absolute;
z-index: 10;
top: 100%;
right: 0;
min-width: 130px;
padding: 5px;
border: 1px solid var(--stroke);
border-radius: 8px;
background: var(--bg1, #1a1d24);
box-shadow: 0 10px 24px rgb(0 0 0 / 45%);
}
.teamMenuPopup button {
width: 100%;
padding: 7px 9px;
border: 0;
border-radius: 5px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.teamMenuPopup button:hover {
background: rgb(255 255 255 / 8%);
}
.teamBody {
display: grid;
gap: 5px;
min-height: 28px;
padding: 6px;
}
.playerRow {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: center;
gap: 9px;
width: 100%;
padding: 5px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: inherit;
text-align: left;
cursor: grab;
}
.playerRow:hover,
.playerRowSelected {
border-color: var(--color-accent, #c9a227);
background: rgb(255 255 255 / 5%);
}
.playerRow > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropTarget {
padding: 8px;
color: var(--text2);
font-size: 11px;
text-align: center;
}
.editor {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
overflow: auto;
padding: 22px;
border: 1px solid var(--stroke);
border-radius: 12px;
background: var(--color-overlay-dark-3);
}
.editorDropOver {
border-color: var(--color-accent, #c9a227);
}
.field {
display: grid;
gap: 6px;
width: min(420px, 100%);
color: var(--text2);
font-size: 12px;
font-weight: 700;
}
.field input[type='color'] {
width: 100%;
height: 38px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: 8px;
background: transparent;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
@media (max-width: 760px) {
.body {
grid-template-columns: 1fr;
}
}
+665
View File
@@ -0,0 +1,665 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal, flushSync } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
import {
DEFAULT_PLAYER_IMAGE_OFFSET,
DEFAULT_PLAYER_IMAGE_SCALE,
DEFAULT_PLAYER_RING_COLOR,
} from '../../shared/types/appPlayers';
import { getDndApi } from '../shared/dndApi';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
import { Button, Input } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import {
filterMaterialImagePaths,
getDroppedFileEntries,
pickFirstMaterialImagePath,
useFileDropZone,
} from './fileDrop';
import { useEditorI18n } from './i18n/EditorI18nContext';
import playerStyles from './PlayersModals.module.css';
const PLAYER_DND_MIME = 'application/x-dnd-player-id';
function PlayerRow({
player,
selected,
onSelect,
}: {
player: AppPlayer;
selected: boolean;
onSelect: () => void;
}) {
const url = usePlayerImageUrl(player.id);
return (
<button
type="button"
data-testid={`player-row-${player.id}`}
className={[playerStyles.playerRow, selected ? playerStyles.playerRowSelected : '']
.filter(Boolean)
.join(' ')}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(PLAYER_DND_MIME, player.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={onSelect}
>
<PlayerTokenView
name={player.name}
imageUrl={url}
ringColor={player.ringColor}
imageOffset={player.imageOffset}
imageScale={player.imageScale}
sizePx={48}
hideName
/>
<span>{player.name}</span>
</button>
);
}
function TeamSection({
team,
players,
selectedId,
onSelect,
onAssign,
onEdit,
onDelete,
}: {
team: AppPlayerTeam | null;
players: AppPlayer[];
selectedId: PlayerId | null;
onSelect: (id: PlayerId) => void;
onAssign: (id: PlayerId, teamId: PlayerTeamId | null) => void;
onEdit?: () => void;
onDelete?: () => void;
}) {
const { t } = useEditorI18n();
return (
<section
className={playerStyles.team}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(PLAYER_DND_MIME)) e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
const id = e.dataTransfer.getData(PLAYER_DND_MIME);
if (id) onAssign(id as PlayerId, team?.id ?? null);
}}
>
<div className={playerStyles.teamHeader}>
{team ? <span className={playerStyles.teamDot} style={{ background: team.color }} /> : null}
<span className={playerStyles.teamName}>{team?.name ?? t('players.ungrouped')}</span>
{team ? (
<details className={playerStyles.teamMenu}>
<summary aria-label={t('players.teamMenu')}></summary>
<div className={playerStyles.teamMenuPopup}>
<button type="button" onClick={onEdit}>
{t('common.edit')}
</button>
<button type="button" onClick={onDelete}>
{t('common.delete')}
</button>
</div>
</details>
) : null}
</div>
<div className={playerStyles.teamBody}>
{players.map((player) => (
<PlayerRow
key={player.id}
player={player}
selected={player.id === selectedId}
onSelect={() => onSelect(player.id)}
/>
))}
{players.length === 0 ? <div className={playerStyles.dropTarget}>{t('players.dropHere')}</div> : null}
</div>
</section>
);
}
export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useEditorI18n();
const api = getDndApi();
const { players, teams } = useAppPlayers();
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<PlayerId | null>(null);
const [name, setName] = useState('');
const [ringColor, setRingColor] = useState(DEFAULT_PLAYER_RING_COLOR);
const [imageOffset, setImageOffset] = useState(DEFAULT_PLAYER_IMAGE_OFFSET);
const [imageScale, setImageScale] = useState(DEFAULT_PLAYER_IMAGE_SCALE);
const [filePath, setFilePath] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const [progress, setProgress] = useState({ percent: 0, detail: '' });
/** Electron не поддерживает window.prompt — форма команды в сайдбаре. */
const [teamForm, setTeamForm] = useState<{
id: PlayerTeamId | null;
name: string;
color: string;
} | null>(null);
const [pendingDeletePlayer, setPendingDeletePlayer] = useState<AppPlayer | null>(null);
const [pendingDeleteTeam, setPendingDeleteTeam] = useState<AppPlayerTeam | null>(null);
const appearanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selected = players.find((p) => p.id === selectedId) ?? null;
const selectedUrl = usePlayerImageUrl(selected?.id);
const resetModalState = useCallback(() => {
if (appearanceTimerRef.current) {
clearTimeout(appearanceTimerRef.current);
appearanceTimerRef.current = null;
}
setQuery('');
setSelectedId(null);
setName('');
setRingColor(DEFAULT_PLAYER_RING_COLOR);
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
setFilePath(null);
setPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return null;
});
setAdding(false);
setSaving(false);
setProgress({ percent: 0, detail: '' });
setTeamForm(null);
setPendingDeletePlayer(null);
setPendingDeleteTeam(null);
}, []);
const handleClose = useCallback(() => {
if (saving) return;
resetModalState();
onClose();
}, [onClose, resetModalState, saving]);
useEffect(() => {
if (!open) return;
setSelectedId((current) =>
current && players.some((p) => p.id === current) ? current : (players[0]?.id ?? null),
);
}, [open, players]);
// Синхронизировать форму только при смене выбранного игрока — не при каждом
// players.stateChanged (иначе сбрасывается несохранённый zoom/pan).
useEffect(() => {
if (adding) return;
if (!selectedId) return;
const p = players.find((item) => item.id === selectedId);
if (!p) return;
setName(p.name);
setRingColor(p.ringColor);
setImageOffset(p.imageOffset);
setImageScale(p.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
setFilePath(null);
setPreviewUrl(null);
// eslint-disable-next-line react-hooks/exhaustive-deps -- только selectedId / adding
}, [adding, selectedId]);
useEffect(() => {
if (!open) return;
return api.on(ipcChannels.players.upsertProgress, (event) => {
setProgress({
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
detail: event.detail?.trim() ?? t('players.savingWait'),
});
});
}, [api, open, t]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || saving) return;
if (pendingDeletePlayer) {
setPendingDeletePlayer(null);
return;
}
if (pendingDeleteTeam) {
setPendingDeleteTeam(null);
return;
}
if (teamForm) {
setTeamForm(null);
return;
}
handleClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [handleClose, open, pendingDeletePlayer, pendingDeleteTeam, saving, teamForm]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? players.filter((p) => p.name.toLowerCase().includes(q)) : players;
}, [players, query]);
const beginAdd = () => {
setAdding(true);
setSelectedId(null);
setName('');
setRingColor(DEFAULT_PLAYER_RING_COLOR);
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
setFilePath(null);
setPreviewUrl(null);
};
const setPreviewFromPathAndUrl = (path: string, url: string) => {
setFilePath(path);
setPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return url || null;
});
};
const pickImage = async () => {
const result = await api.invoke(ipcChannels.players.pickImage, {});
if (result.canceled) return;
setPreviewFromPathAndUrl(result.filePath, result.previewDataUrl);
};
const applyDroppedImage = (path: string, previewFromFile: string) => {
if (!adding && !selected) beginAdd();
setPreviewFromPathAndUrl(path, previewFromFile);
};
const drop = useFileDropZone({
disabled: saving,
filterPaths: filterMaterialImagePaths,
onDropPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
if (!picked) return;
applyDroppedImage(picked, '');
},
});
const appearanceLatestRef = useRef({
selected,
adding,
saving,
ringColor,
imageOffset,
imageScale,
});
appearanceLatestRef.current = { selected, adding, saving, ringColor, imageOffset, imageScale };
const persistAppearance = (patch: {
ringColor?: string;
imageOffset?: typeof imageOffset;
imageScale?: number;
}) => {
const snap = appearanceLatestRef.current;
if (!snap.selected || snap.adding || snap.saving) return;
const payload = {
id: snap.selected.id,
teamId: snap.selected.teamId,
name: snap.selected.name,
ringColor: patch.ringColor ?? snap.ringColor,
imageOffset: patch.imageOffset ?? snap.imageOffset,
imageScale: patch.imageScale ?? snap.imageScale,
};
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
appearanceTimerRef.current = setTimeout(() => {
appearanceTimerRef.current = null;
void api.invoke(ipcChannels.players.upsert, payload);
}, 180);
};
useEffect(() => {
return () => {
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
};
}, []);
const save = async () => {
const trimmed = name.trim();
if (!trimmed || (selected === null && filePath === null)) return;
if (appearanceTimerRef.current) {
clearTimeout(appearanceTimerRef.current);
appearanceTimerRef.current = null;
}
flushSync(() => {
setSaving(true);
setProgress({ percent: 0, detail: t('players.savingWait') });
});
try {
const result = await api.invoke(ipcChannels.players.upsert, {
...(selected ? { id: selected.id, teamId: selected.teamId } : {}),
name: trimmed,
...(filePath ? { filePath } : {}),
ringColor,
imageOffset,
imageScale,
});
setAdding(false);
setSelectedId(result.player.id);
} finally {
setSaving(false);
}
};
if (!open) return null;
const activeUrl = previewUrl ?? selectedUrl;
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden />
<div
role="dialog"
aria-modal="true"
data-testid="players-modal"
className={[styles.modalDialog, playerStyles.dialog].join(' ')}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.managerTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={handleClose}
className={styles.modalClose}
disabled={saving}
>
×
</button>
</div>
<div className={playerStyles.body}>
<aside className={playerStyles.sidebar}>
<Input value={query} onChange={setQuery} placeholder={t('players.search')} />
<div className={playerStyles.sidebarActions}>
<Button variant="primary" data-testid="players-add" onClick={beginAdd}>
{t('players.add')}
</Button>
<Button
data-testid="players-add-team"
onClick={() =>
setTeamForm({
id: null,
name: '',
color: DEFAULT_PLAYER_RING_COLOR,
})
}
>
{t('players.addTeam')}
</Button>
</div>
{teamForm ? (
<div className={playerStyles.teamForm} data-testid="players-team-form">
<label className={playerStyles.teamFormField}>
<span>{t('players.teamName')}</span>
<Input
value={teamForm.name}
onChange={(v) => setTeamForm((prev) => (prev ? { ...prev, name: v } : prev))}
placeholder={t('players.teamName')}
autoFocus
/>
</label>
<label className={playerStyles.teamFormField}>
<span>{t('players.teamColor')}</span>
<input
type="color"
value={teamForm.color}
onChange={(e) =>
setTeamForm((prev) =>
prev ? { ...prev, color: e.currentTarget.value } : prev,
)
}
/>
</label>
<div className={playerStyles.teamFormActions}>
<Button
variant="primary"
disabled={!teamForm.name.trim()}
onClick={() => {
const trimmed = teamForm.name.trim();
if (!trimmed) return;
void api
.invoke(ipcChannels.players.upsertTeam, {
...(teamForm.id ? { id: teamForm.id } : {}),
name: trimmed,
color: teamForm.color,
})
.then(() => setTeamForm(null));
}}
>
{t('common.save')}
</Button>
<Button onClick={() => setTeamForm(null)}>{t('common.cancel')}</Button>
</div>
</div>
) : null}
<div className={playerStyles.list}>
{teams.map((team) => (
<TeamSection
key={team.id}
team={team}
players={filtered.filter((p) => p.teamId === team.id)}
selectedId={selectedId}
onSelect={(id) => {
setAdding(false);
setSelectedId(id);
}}
onAssign={(playerId, teamId) =>
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
}
onEdit={() =>
setTeamForm({
id: team.id,
name: team.name,
color: team.color,
})
}
onDelete={() => setPendingDeleteTeam(team)}
/>
))}
<TeamSection
team={null}
players={filtered.filter((p) => p.teamId === null)}
selectedId={selectedId}
onSelect={(id) => {
setAdding(false);
setSelectedId(id);
}}
onAssign={(playerId, teamId) =>
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
}
/>
</div>
</aside>
<main
className={[playerStyles.editor, drop.dragOver ? playerStyles.editorDropOver : '']
.filter(Boolean)
.join(' ')}
onDragEnter={drop.onDragEnter}
onDragLeave={drop.onDragLeave}
onDragOver={drop.onDragOver}
onDrop={(e) => {
drop.onDrop(e);
const entries = getDroppedFileEntries(e);
const files = e.dataTransfer?.files;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i]!;
if (!pickFirstMaterialImagePath([entry.path])) continue;
const file = files?.[i];
applyDroppedImage(entry.path, file ? URL.createObjectURL(file) : '');
return;
}
}}
>
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('players.dropHint')}</div> : null}
{adding || selected ? (
<>
<PlayerTokenView
data-testid="player-token-preview"
name={name}
imageUrl={activeUrl}
ringColor={ringColor}
imageOffset={imageOffset}
imageScale={imageScale}
panEnabled
onImageOffsetChange={(next) => {
setImageOffset(next);
persistAppearance({ imageOffset: next });
}}
onImageScaleChange={(next) => {
setImageScale(next);
persistAppearance({ imageScale: next });
}}
sizePx={220}
/>
<label className={playerStyles.field}>
<span>{t('players.name')}</span>
<Input value={name} onChange={setName} placeholder={t('players.namePlaceholder')} />
</label>
<label className={playerStyles.field}>
<span>{t('players.ringColor')}</span>
<input
type="color"
value={ringColor}
onChange={(e) => {
const next = e.currentTarget.value;
setRingColor(next);
persistAppearance({ ringColor: next });
}}
/>
</label>
<div className={playerStyles.actions}>
<Button data-testid="players-choose-image" onClick={() => void pickImage()}>
{t('players.chooseImage')}
</Button>
<Button
variant="primary"
data-testid="players-save"
disabled={!name.trim() || (!selected && !filePath) || saving}
onClick={() => void save()}
>
{saving ? t('common.saving') : t('common.save')}
</Button>
{selected ? (
<Button onClick={() => setPendingDeletePlayer(selected)}>{t('common.delete')}</Button>
) : null}
</div>
</>
) : (
<div className={styles.muted}>{t('players.selectPrompt')}</div>
)}
</main>
</div>
</div>
{pendingDeletePlayer
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDeletePlayer(null)}
/>
<div
role="dialog"
aria-modal="true"
data-testid="players-delete-confirm"
className={styles.modalDialog}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.deleteTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDeletePlayer(null)}
>
×
</button>
</div>
<div className={styles.muted}>
{t('players.deleteConfirm', { name: pendingDeletePlayer.name })}
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDeletePlayer(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeletePlayer.id;
setPendingDeletePlayer(null);
void api.invoke(ipcChannels.players.delete, { id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{pendingDeleteTeam
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDeleteTeam(null)}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.deleteTeamTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDeleteTeam(null)}
>
×
</button>
</div>
<div className={styles.muted}>
{t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })}
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDeleteTeam(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteTeam.id;
setPendingDeleteTeam(null);
void api.invoke(ipcChannels.players.deleteTeam, { id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{saving ? (
<div className={styles.progressOverlay} role="dialog" aria-busy data-testid="players-save-progress">
<div className={styles.progressModal}>
<div className={styles.progressTitle}>{t('players.savingTitle')}</div>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{ width: `${String(progress.percent)}%` }} />
</div>
<div className={styles.progressMeta}>
<div>{progress.detail}</div>
<div>{progress.percent}%</div>
</div>
</div>
</div>
) : null}
</>,
document.body,
);
}
@@ -0,0 +1,182 @@
.dialog {
width: min(720px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
grid-template-rows: auto 1fr auto;
}
.editorShell {
display: grid;
grid-template-rows: auto 1fr;
min-height: 360px;
max-height: min(560px, calc(100vh - 200px));
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
overflow: hidden;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px;
border-bottom: 1px solid var(--stroke);
background: var(--color-panel-2);
}
.toolbarGroup {
display: flex;
gap: 2px;
}
.toolbarSep {
width: 1px;
align-self: stretch;
margin: 2px 4px;
background: var(--stroke-2);
}
.toolBtn {
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: var(--radius-xs);
background: transparent;
color: var(--text1);
cursor: pointer;
font: inherit;
font-size: var(--text-sm);
font-weight: 700;
line-height: 1;
display: grid;
place-items: center;
}
.toolBtn:hover:not(:disabled) {
background: var(--panel);
color: var(--text0);
}
.toolBtnActive {
border-color: var(--accent-border);
background: var(--accent-fill-soft);
color: var(--accent2);
}
.toolBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toolIcon {
display: block;
}
.editorContent {
min-height: 0;
overflow: auto;
padding: 14px 16px;
}
.editorContent :global(.tiptap) {
min-height: 280px;
outline: none;
color: var(--text0);
font-size: var(--text-md);
line-height: 1.55;
word-break: break-word;
}
.editorContent :global(.tiptap p.is-editor-empty:first-child::before) {
color: var(--text2);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Shared rich-text look (editor + inspector preview) */
.prose :global(p) {
margin: 0 0 0.65em;
}
.prose :global(p:last-child) {
margin-bottom: 0;
}
.prose :global(h2),
.prose :global(h3) {
margin: 0.85em 0 0.4em;
font-weight: 800;
color: var(--text0);
line-height: 1.3;
}
.prose :global(h2) {
font-size: 1.2em;
}
.prose :global(h3) {
font-size: 1.08em;
}
.prose :global(ul),
.prose :global(ol) {
margin: 0 0 0.65em;
padding-left: 1.35em;
}
.prose :global(li) {
margin: 0.15em 0;
}
.prose :global(strong) {
font-weight: 800;
color: var(--text0);
}
.prose :global(em) {
font-style: italic;
}
.prose :global(u) {
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :global(a) {
color: var(--accent2);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :global(blockquote) {
margin: 0 0 0.65em;
padding: 0.35em 0 0.35em 0.85em;
border-left: 3px solid var(--accent-border);
color: var(--text1);
}
.prose :global(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
padding: 0.1em 0.35em;
border-radius: 4px;
background: var(--color-overlay-dark-4);
color: var(--text0);
}
.prose :global(pre) {
margin: 0 0 0.65em;
padding: 10px 12px;
border-radius: var(--radius-xs);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-5);
overflow-x: auto;
}
.prose :global(pre code) {
padding: 0;
background: transparent;
}
@@ -0,0 +1,250 @@
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import React, { useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { normalizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import modalStyles from './SceneDescriptionModal.module.css';
type SceneDescriptionModalProps = {
initialHtml: string;
onClose: () => void;
onSave: (html: string) => void;
};
function ToolbarIcon({ path, size = 14 }: { path: string; size?: number }) {
return (
<svg className={modalStyles.toolIcon} viewBox="0 0 24 24" width={size} height={size} aria-hidden>
<path fill="currentColor" d={path} />
</svg>
);
}
function ToolButton({
active = false,
disabled = false,
title,
onClick,
children,
}: {
active?: boolean;
disabled?: boolean;
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
aria-pressed={active}
disabled={disabled}
className={[modalStyles.toolBtn, active ? modalStyles.toolBtnActive : ''].filter(Boolean).join(' ')}
onClick={onClick}
>
{children}
</button>
);
}
export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDescriptionModalProps) {
const { t } = useEditorI18n();
const extensions = useMemo(
() => [
StarterKit.configure({
heading: { levels: [2, 3] },
codeBlock: false,
link: false,
}),
Placeholder.configure({
placeholder: t('scene.descriptionPlaceholder'),
}),
],
[t],
);
const editor = useEditor({
extensions,
content: initialHtml || '',
immediatelyRender: false,
shouldRerenderOnTransaction: true,
editorProps: {
attributes: {
class: [modalStyles.prose, 'tiptap'].join(' '),
'aria-label': t('scene.descriptionModalTitle'),
},
},
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const toolbarState = useEditorState({
editor,
selector: ({ editor: ed }) => ({
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
underline: Boolean(ed && !ed.isDestroyed && ed.isActive('underline')),
bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
blockquote: Boolean(ed && !ed.isDestroyed && ed.isActive('blockquote')),
}),
});
const handleSave = () => {
if (!editor || editor.isDestroyed) return;
let raw = '';
try {
raw = editor.getHTML();
} catch {
return;
}
onSave(normalizeSceneDescriptionHtml(raw));
};
if (!editor || editor.isDestroyed) {
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={[styles.modalDialog, modalStyles.dialog].join(' ')}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('scene.descriptionModalTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={modalStyles.editorShell} />
</div>
</>,
document.body,
);
}
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={[styles.modalDialog, modalStyles.dialog].join(' ')}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('scene.descriptionModalTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={modalStyles.editorShell}>
<div className={modalStyles.toolbar} role="toolbar" aria-label={t('scene.descriptionToolbar')}>
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionBold')}
active={toolbarState?.bold ?? false}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<strong>B</strong>
</ToolButton>
<ToolButton
title={t('scene.descriptionItalic')}
active={toolbarState?.italic ?? false}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<em>I</em>
</ToolButton>
<ToolButton
title={t('scene.descriptionUnderline')}
active={toolbarState?.underline ?? false}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<span style={{ textDecoration: 'underline' }}>U</span>
</ToolButton>
</div>
<div className={modalStyles.toolbarSep} />
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionHeading2')}
active={toolbarState?.h2 ?? false}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
H2
</ToolButton>
<ToolButton
title={t('scene.descriptionHeading3')}
active={toolbarState?.h3 ?? false}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
H3
</ToolButton>
<ToolButton
title={t('scene.descriptionQuote')}
active={toolbarState?.blockquote ?? false}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
</ToolButton>
</div>
<div className={modalStyles.toolbarSep} />
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionBulletList')}
active={toolbarState?.bulletList ?? false}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<ToolbarIcon path="M4 6h2v2H4V6zm0 5h2v2H4v-2zm0 5h2v2H4v-2zm4-10h12v2H8V6zm0 5h12v2H8v-2zm0 5h12v2H8v-2z" />
</ToolButton>
<ToolButton
title={t('scene.descriptionOrderedList')}
active={toolbarState?.orderedList ?? false}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
</ToolButton>
</div>
</div>
<div className={modalStyles.editorContent}>
<EditorContent editor={editor} />
</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="primary" onClick={handleSave}>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
@@ -0,0 +1,965 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
collectSceneIdsForSelections,
findNpcNameConflicts,
findSceneTitleConflicts,
listExportedNpcsFromBundle,
storylineSelectionKey,
type NpcImportResolution,
type NpcNameConflict,
type SceneImportResolution,
type SceneTitleConflict,
type StorylineImportMergeReport,
type StorylineLabels,
type StorylineListItem,
type StorylineSelection,
} from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
export type ExportNpcOption = { id: string; name: string };
type ExportProjectModalProps = {
open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null;
storylineLabels: StorylineLabels;
loadStorylines: (
projectId: ProjectId,
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
onClose: () => void;
onExport: (
projectId: ProjectId,
selections: StorylineSelection[],
npcIds: string[],
) => Promise<void>;
};
export function ExportProjectModal({
open,
projects,
initialProjectId,
storylineLabels: _storylineLabels,
loadStorylines,
onClose,
onExport,
}: ExportProjectModalProps) {
const { t } = useEditorI18n();
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
const [loadingStorylines, setLoadingStorylines] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setProjectId(initialProjectId);
setSaving(false);
setError(null);
setSelectedKeys(new Set());
setSelectedNpcIds(new Set());
setStep('storylines');
setNpcs([]);
}, [initialProjectId, open]);
useEffect(() => {
if (!open || !projectId) {
setStorylines([]);
setNpcs([]);
return;
}
let cancelled = false;
setLoadingStorylines(true);
void (async () => {
try {
const res = await loadStorylines(projectId);
if (cancelled) return;
const list = Array.isArray(res?.storylines) ? res.storylines : [];
const npcList = Array.isArray(res?.npcs) ? res.npcs : [];
setStorylines(list);
setNpcs(npcList);
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
setSelectedNpcIds(new Set(npcList.map((n) => n.id)));
setStep('storylines');
} catch (e) {
if (!cancelled) {
setStorylines([]);
setNpcs([]);
setSelectedKeys(new Set());
setSelectedNpcIds(new Set());
setError(e instanceof Error ? e.message : String(e));
}
} finally {
if (!cancelled) setLoadingStorylines(false);
}
})();
return () => {
cancelled = true;
};
}, [loadStorylines, open, projectId]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (step === 'npcs') setStep('storylines');
else onClose();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open, step]);
if (!open) return null;
const canContinueStorylines =
projectId !== null &&
projects.some((p) => p.id === projectId) &&
selectedKeys.size > 0 &&
!loadingStorylines;
const toggleKey = (key: string, disabled?: boolean) => {
if (disabled) return;
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleNpc = (id: string) => {
setSelectedNpcIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectedSelections = storylines
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
.map((item) => item.selection);
const runExport = (npcIds: string[]) => {
if (!projectId || !canContinueStorylines) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onExport(projectId, selectedSelections, npcIds);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
};
const goNextFromStorylines = () => {
if (!canContinueStorylines) return;
if (npcs.length === 0) {
runExport([]);
return;
}
setStep('npcs');
};
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>
{step === 'storylines' ? t('export.title') : t('export.npcsTitle')}
</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
{step === 'storylines' ? (
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('export.project')}</div>
<Select
value={projectId ?? ''}
onChange={(next) => setProjectId((next as ProjectId) || null)}
disabled={projects.length === 0 || saving}
ariaLabel={t('export.project')}
options={projects.map((p) => ({
value: p.id,
label: `${p.name} (${p.fileName})`,
}))}
/>
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
{loadingStorylines ? (
<div className={styles.muted}>{t('storyline.loading')}</div>
) : storylines.length === 0 ? (
<div className={styles.muted}>{t('storyline.empty')}</div>
) : (
<div className={styles.storylineChecklist}>
{storylines.map((item) => {
const key = storylineSelectionKey(item.selection);
const checked = selectedKeys.has(key);
const disabled = item.disabled === true;
return (
<label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input
type="checkbox"
checked={checked}
disabled={disabled || saving}
onChange={() => toggleKey(key, disabled)}
/>
<span>{item.label}</span>
{disabled && item.disabledReason === 'main_exists' ? (
<span className={styles.muted}> {t('storyline.mainExistsHint')}</span>
) : null}
</label>
);
})}
</div>
)}
<div className={styles.muted}>{t('export.hint')}</div>
</div>
) : (
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('export.npcsHint')}</div>
<div className={styles.storylineChecklist}>
{npcs.map((n) => (
<label key={n.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedNpcIds.has(n.id)}
disabled={saving}
onChange={() => toggleNpc(n.id)}
/>
<span>{n.name}</span>
</label>
))}
</div>
<Button
disabled={saving || npcs.length === 0}
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
>
{t('export.selectAllNpcs')}
</Button>
</div>
)}
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
{step === 'npcs' ? (
<Button onClick={() => setStep('storylines')} disabled={saving}>
{t('export.back')}
</Button>
) : (
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
{t('common.cancel')}
</Button>
)}
{step === 'storylines' ? (
<Button
variant="primary"
disabled={!canContinueStorylines || saving}
onClick={goNextFromStorylines}
>
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
</Button>
) : (
<Button
variant="primary"
disabled={saving}
onClick={() => runExport([...selectedNpcIds])}
>
{t('export.saveAs')}
</Button>
)}
</div>
</div>
</>,
document.body,
);
}
export type ImportPeekResult = {
kind: 'file' | 'project';
filePath?: string;
sourceProjectId?: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
};
export type ImportSourceSelection =
| { kind: 'file'; filePath: string; fileName: string }
| { kind: 'project'; sourceProjectId: ProjectId };
type ImportSourceModalProps = {
open: boolean;
/** false — только импорт из файла (полный импорт проекта). */
canImportFromProject: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
currentProjectId: ProjectId | null;
pickFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
onClose: () => void;
onContinue: (selection: ImportSourceSelection) => Promise<void>;
};
export function ImportSourceModal({
open,
canImportFromProject,
projects,
currentProjectId,
pickFile,
onClose,
onContinue,
}: ImportSourceModalProps) {
const { t } = useEditorI18n();
const [importKind, setImportKind] = useState<'project' | 'file'>('file');
const [sourceProjectId, setSourceProjectId] = useState<ProjectId | null>(null);
const [pickedFile, setPickedFile] = useState<{ path: string; name: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const availableProjects = useMemo(
() => projects.filter((p) => p.id !== currentProjectId),
[currentProjectId, projects],
);
useEffect(() => {
if (!open) return;
setImportKind(canImportFromProject && availableProjects.length > 0 ? 'project' : 'file');
setSourceProjectId(availableProjects[0]?.id ?? null);
setPickedFile(null);
setSubmitting(false);
setError(null);
}, [availableProjects, canImportFromProject, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const canContinue =
!submitting &&
(importKind === 'project' ? canImportFromProject && sourceProjectId !== null : pickedFile !== null);
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importSource.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
{canImportFromProject ? (
<div className={styles.fieldGroup}>
<div className={styles.fieldLabel}>{t('importSource.type')}</div>
<Select
value={importKind}
onChange={(next) => setImportKind(next as 'project' | 'file')}
disabled={submitting}
ariaLabel={t('importSource.type')}
options={[
{ value: 'project', label: t('importSource.fromProject') },
{ value: 'file', label: t('importSource.fromFile') },
]}
/>
</div>
) : (
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
)}
{importKind === 'project' && canImportFromProject ? (
<div className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : ''].filter(Boolean).join(' ')}>
<div className={styles.fieldLabel}>{t('importSource.project')}</div>
<Select
value={sourceProjectId ?? ''}
onChange={(next) => setSourceProjectId((next as ProjectId) || null)}
disabled={availableProjects.length === 0 || submitting}
ariaLabel={t('importSource.project')}
options={availableProjects.map((p) => ({
value: p.id,
label: `${p.name} (${p.fileName})`,
}))}
/>
{availableProjects.length === 0 ? (
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
) : null}
</div>
) : (
<div
className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : '']
.filter(Boolean)
.join(' ')}
>
<div className={styles.fieldLabel}>{t('importSource.file')}</div>
<div className={styles.importFileRow}>
<Button
disabled={submitting}
onClick={() => {
void (async () => {
setError(null);
const res = await pickFile();
if (res.canceled) return;
const name = res.filePath.split(/[/\\]/).pop() ?? res.filePath;
setPickedFile({ path: res.filePath, name });
})();
}}
>
{t('importSource.chooseFile')}
</Button>
<span className={styles.muted}>
{pickedFile ? pickedFile.name : t('importSource.noFileSelected')}
</span>
</div>
</div>
)}
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canContinue}
onClick={() => {
void (async () => {
setSubmitting(true);
setError(null);
try {
if (importKind === 'project' && canImportFromProject && sourceProjectId) {
await onContinue({ kind: 'project', sourceProjectId });
} else if (pickedFile) {
await onContinue({ kind: 'file', filePath: pickedFile.path, fileName: pickedFile.name });
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
})();
}}
>
{t('importStoryline.continue')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ImportStorylinesModalProps = {
open: boolean;
sourceName: string;
storylines: StorylineListItem[];
onClose: () => void;
onContinue: (selections: StorylineSelection[]) => void;
};
export function ImportStorylinesModal({
open,
sourceName,
storylines,
onClose,
onContinue,
}: ImportStorylinesModalProps) {
const { t } = useEditorI18n();
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
useEffect(() => {
if (!open) return;
setSelectedKeys(new Set());
}, [open, sourceName]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const toggleKey = (key: string, disabled?: boolean) => {
if (disabled) return;
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectedSelections = storylines
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
.map((item) => item.selection);
const canContinue = selectedKeys.size > 0;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('importStoryline.source')}</div>
<div>{sourceName}</div>
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
{storylines.length === 0 ? (
<div className={styles.muted}>{t('storyline.empty')}</div>
) : (
<div className={styles.storylineChecklist}>
{storylines.map((item) => {
const key = storylineSelectionKey(item.selection);
const disabled = item.disabled === true;
return (
<label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input
type="checkbox"
checked={selectedKeys.has(key)}
disabled={disabled}
onChange={() => toggleKey(key, disabled)}
/>
<span>{item.label}</span>
{disabled && item.disabledReason === 'main_exists' ? (
<span className={styles.muted}> {t('storyline.mainExistsHint')}</span>
) : null}
</label>
);
})}
</div>
)}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="primary" disabled={!canContinue} onClick={() => onContinue(selectedSelections)}>
{t('importStoryline.continue')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type SceneConflictModalProps = {
open: boolean;
conflicts: SceneTitleConflict[];
onClose: () => void;
onConfirm: (resolutions: SceneImportResolution[]) => void;
};
export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: SceneConflictModalProps) {
const { t } = useEditorI18n();
const [choices, setChoices] = useState<Record<string, 'create' | SceneId>>({});
useEffect(() => {
if (!open) return;
const init: Record<string, 'create' | SceneId> = {};
for (const c of conflicts) {
init[c.sourceSceneId] = c.matches[0]?.sceneId ?? 'create';
}
setChoices(init);
}, [conflicts, open]);
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={`${styles.modalDialog} ${styles.modalDialogWide}`}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.conflictsTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<p className={styles.muted}>{t('importStoryline.conflictsHint')}</p>
<div className={styles.conflictList}>
{conflicts.map((c) => (
<div key={c.sourceSceneId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceTitle}</div>
<Select
value={choices[c.sourceSceneId] ?? 'create'}
onChange={(v) => {
setChoices((prev) => ({
...prev,
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
}));
}}
ariaLabel={c.sourceTitle}
options={[
{ value: 'create', label: t('importStoryline.createNewScene') },
...c.matches.map((m) => ({
value: m.sceneId,
label: t('importStoryline.useExistingScene', { title: m.title }),
})),
]}
/>
</div>
))}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const resolutions: SceneImportResolution[] = conflicts.map((c) => {
const choice = choices[c.sourceSceneId] ?? 'create';
if (choice === 'create') return { sourceSceneId: c.sourceSceneId, mode: 'create' };
return { sourceSceneId: c.sourceSceneId, mode: 'use', targetSceneId: choice };
});
onConfirm(resolutions);
}}
>
{t('importStoryline.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type NpcConflictModalProps = {
open: boolean;
conflicts: NpcNameConflict[];
onClose: () => void;
onConfirm: (resolutions: NpcImportResolution[]) => void;
};
function defaultNpcConflictChoices(conflicts: NpcNameConflict[]): Record<string, 'create' | string> {
const init: Record<string, 'create' | string> = {};
for (const c of conflicts) {
init[c.sourceNpcId] = c.matches[0]?.npcId ?? 'create';
}
return init;
}
export function NpcConflictModal({ open, conflicts, onClose, onConfirm }: NpcConflictModalProps) {
if (!open) return null;
const remountKey = conflicts.map((c) => c.sourceNpcId).join('|');
return (
<NpcConflictModalBody
key={remountKey}
conflicts={conflicts}
onClose={onClose}
onConfirm={onConfirm}
/>
);
}
function NpcConflictModalBody({
conflicts,
onClose,
onConfirm,
}: {
conflicts: NpcNameConflict[];
onClose: () => void;
onConfirm: (resolutions: NpcImportResolution[]) => void;
}) {
const { t } = useEditorI18n();
const [choices, setChoices] = useState(() => defaultNpcConflictChoices(conflicts));
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div
role="dialog"
aria-modal="true"
className={[styles.modalDialog, styles.modalDialogWide].filter(Boolean).join(' ')}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.npcConflictsTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<p className={styles.muted}>{t('importStoryline.npcConflictsHint')}</p>
<div className={styles.conflictList}>
{conflicts.map((c) => (
<div key={c.sourceNpcId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceName}</div>
<Select
value={choices[c.sourceNpcId] ?? 'create'}
onChange={(v) => {
setChoices((prev) => ({
...prev,
[c.sourceNpcId]: v === 'create' ? 'create' : v,
}));
}}
ariaLabel={c.sourceName}
options={[
{ value: 'create', label: t('importStoryline.createNewNpc') },
...c.matches.map((m) => ({
value: m.npcId,
label: t('importStoryline.useExistingNpc', { name: m.name }),
})),
]}
/>
</div>
))}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const resolutions: NpcImportResolution[] = conflicts.map((c) => {
const choice = choices[c.sourceNpcId] ?? 'create';
if (choice === 'create') return { sourceNpcId: c.sourceNpcId, mode: 'create' };
return { sourceNpcId: c.sourceNpcId, mode: 'use', targetNpcId: choice };
});
onConfirm(resolutions);
}}
>
{t('importStoryline.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ImportReportModalProps = {
open: boolean;
report: StorylineImportMergeReport | null;
onClose: () => void;
};
export function ImportReportModal({ open, report, onClose }: ImportReportModalProps) {
const { t } = useEditorI18n();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open || !report) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.reportTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<ul className={styles.reportList}>
<li>{t('importStoryline.reportLines', { count: report.storylinesImported })}</li>
<li>{t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}</li>
<li>{t('importStoryline.reportScenesReused', { count: report.scenesReused })}</li>
<li>{t('importStoryline.reportNpcsCreated', { count: report.npcsCreated })}</li>
<li>{t('importStoryline.reportNpcsReused', { count: report.npcsReused })}</li>
<li>{t('importStoryline.reportNodes', { count: report.graphNodesAdded })}</li>
<li>{t('importStoryline.reportEdges', { count: report.edgesAdded })}</li>
<li>{t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}</li>
<li>{t('importStoryline.reportAssetsReused', { count: report.assetsReused })}</li>
{report.renamedSideTitles.length > 0 ? (
<li>{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}</li>
) : null}
</ul>
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
export function buildSceneResolutionsForImport(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
conflicts: SceneTitleConflict[],
userResolutions: SceneImportResolution[],
): SceneImportResolution[] {
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
const conflictIds = new Set(conflicts.map((c) => c.sourceSceneId));
const bySource = new Map(userResolutions.map((r) => [r.sourceSceneId, r]));
const out: SceneImportResolution[] = [];
for (const sid of sceneIds) {
if (conflictIds.has(sid)) {
const r = bySource.get(sid);
if (r) out.push(r);
continue;
}
out.push({ sourceSceneId: sid, mode: 'create' });
}
return out;
}
export function computeImportConflicts(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
): SceneTitleConflict[] {
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
return findSceneTitleConflicts(targetProject, sourceProject, sceneIds);
}
export function buildNpcResolutionsForImport(
_targetProject: Project,
sourceProject: Project,
conflicts: NpcNameConflict[],
userResolutions: NpcImportResolution[],
): NpcImportResolution[] {
const exported = listExportedNpcsFromBundle(sourceProject);
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
const out: NpcImportResolution[] = [];
for (const n of exported) {
if (conflictIds.has(n.id)) {
const r = bySource.get(n.id);
if (r) out.push(r);
continue;
}
out.push({ sourceNpcId: n.id, mode: 'create' });
}
return out;
}
export function computeNpcImportConflicts(
targetProject: Project,
sourceProject: Project,
): NpcNameConflict[] {
const exported = listExportedNpcsFromBundle(sourceProject);
return findNpcNameConflicts(
targetProject,
sourceProject,
exported.map((n) => n.id),
);
}
export function useStorylineLabels(): StorylineLabels {
const { t } = useEditorI18n();
return useMemo(
() => ({
main: t('storyline.main'),
untitled: t('graph.untitled'),
}),
[t],
);
}
@@ -0,0 +1,216 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
import { Button } from '../../shared/ui/controls';
import styles from '../EditorApp.module.css';
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../help/helpLinkify';
import {
HELP_SECTION_IDS,
helpSectionBodyKey,
helpSectionTitleKey,
type HelpSectionId,
} from '../help/helpSections';
import { useEditorI18n } from '../i18n/EditorI18nContext';
type AppAboutModalProps = {
open: boolean;
onClose: () => void;
appVersion: string | null;
};
export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps) {
const { t, locale } = useEditorI18n();
const appName = locale === 'ru' ? APP_DISPLAY_NAME_RU : APP_DISPLAY_NAME_EN;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('app.about.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.aboutBody}>
<div className={styles.aboutAppName}>{appName}</div>
<div className={styles.aboutTagline}>{t('app.about.tagline')}</div>
<p className={styles.aboutParagraph}>{t('app.about.description')}</p>
<div className={styles.aboutMetaGrid}>
<div className={styles.fieldLabel}>{t('app.about.versionLabel')}</div>
<div>{appVersion ?? '—'}</div>
<div className={styles.fieldLabel}>{t('app.about.developerLabel')}</div>
<div>{t('app.about.developer')}</div>
<div className={styles.fieldLabel}>{t('app.about.supportLabel')}</div>
<div>
<a className={styles.aboutLink} href={`mailto:${t('app.about.supportEmail')}`}>
{t('app.about.supportEmail')}
</a>
</div>
<div className={styles.fieldLabel}>{t('app.about.websiteLabel')}</div>
<div>
<a
className={styles.aboutLink}
href={t('app.about.websiteUrl')}
target="_blank"
rel="noreferrer"
>
{t('app.about.websiteUrl')}
</a>
</div>
</div>
</div>
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type InstructionsModalProps = {
open: boolean;
onClose: () => void;
initialSection?: HelpSectionId;
};
function InstructionsModalBody({
initialSection,
onClose,
}: {
initialSection: HelpSectionId;
onClose: () => void;
}) {
const { t } = useEditorI18n();
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
const contentRef = useRef<HTMLDivElement | null>(null);
const navRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => {
contentRef.current?.scrollTo({ top: 0 });
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
activeNav?.scrollIntoView({ block: 'nearest' });
}, [activeId]);
const linkCatalog = useMemo(
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
[t],
);
const body = t(helpSectionBodyKey(activeId));
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
const goToSection = (id: HelpSectionId) => {
setActiveId(id);
};
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.instructionsDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('help.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.instructionsLayout}>
<div ref={contentRef} className={styles.instructionsContent}>
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
{paragraphs.map((p, i) => (
<p key={i} className={styles.instructionsParagraph}>
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
part.type === 'text' ? (
<React.Fragment key={j}>{part.value}</React.Fragment>
) : (
<button
key={j}
type="button"
className={styles.instructionsInlineLink}
onClick={() => goToSection(part.id)}
>
{part.value}
</button>
),
)}
</p>
))}
</div>
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}>
{HELP_SECTION_IDS.map((id) => (
<button
key={id}
type="button"
className={[
styles.instructionsNavItem,
id === activeId ? styles.instructionsNavItemActive : '',
]
.filter(Boolean)
.join(' ')}
aria-current={id === activeId ? 'true' : undefined}
onClick={() => goToSection(id)}
>
{t(helpSectionTitleKey(id))}
</button>
))}
</nav>
</div>
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) {
if (!open) return null;
const section = initialSection ?? 'overview';
return <InstructionsModalBody key={section} initialSection={section} onClose={onClose} />;
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isPreviewMediaPath,
partitionSceneMediaDrops,
sceneTitleFromMediaPath,
} from './fileDrop';
void test('sceneTitleFromMediaPath strips extension and path', () => {
assert.equal(sceneTitleFromMediaPath('C:\\media\\Forest Gate.png'), 'Forest Gate');
assert.equal(sceneTitleFromMediaPath('/tmp/battle.mp4'), 'battle');
assert.equal(sceneTitleFromMediaPath('onlyname'), 'onlyname');
});
void test('isPreviewMediaPath accepts images and videos', () => {
assert.equal(isPreviewMediaPath('a.png'), true);
assert.equal(isPreviewMediaPath('a.JPG'), true);
assert.equal(isPreviewMediaPath('a.webm'), true);
assert.equal(isPreviewMediaPath('a.mp3'), false);
assert.equal(isPreviewMediaPath('a.pdf'), false);
});
void test('partitionSceneMediaDrops keeps valid and rejects others', () => {
const { accepted, rejected } = partitionSceneMediaDrops([
{ path: 'D:\\a\\one.jpg', name: 'one.jpg' },
{ path: 'D:\\a\\two.mp3', name: 'two.mp3' },
{ path: '', name: 'ghost.png' },
{ path: 'D:\\a\\three.mov', name: 'three.mov' },
]);
assert.deepEqual(
accepted.map((x) => x.name),
['one.jpg', 'three.mov'],
);
assert.deepEqual(rejected, [
{ name: 'two.mp3', reason: 'unsupported' },
{ name: 'ghost.png', reason: 'no_path' },
]);
});
+187
View File
@@ -0,0 +1,187 @@
import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
import { getDndApi } from '../shared/dndApi';
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
const MATERIAL_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const PREVIEW_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.webp',
'.gif',
'.bmp',
'.mp4',
'.webm',
'.mov',
]);
function fileExtension(filePath: string): string {
const dot = filePath.lastIndexOf('.');
return dot >= 0 ? filePath.slice(dot).toLowerCase() : '';
}
export type DroppedFileEntry = { path: string; name: string };
export type SceneMediaDropRejectReason = 'unsupported' | 'no_path';
export type SceneMediaDropRejected = { name: string; reason: SceneMediaDropRejectReason };
export function getDroppedFilePaths(e: DragEvent): string[] {
return getDroppedFileEntries(e)
.map((entry) => entry.path)
.filter((path) => path.length > 0);
}
export function getDroppedFileEntries(e: DragEvent): DroppedFileEntry[] {
const files = e.dataTransfer?.files;
if (!files?.length) return [];
const getPathForFile = getDndApi().getPathForFile;
return Array.from(files).map((file) => ({
path: getPathForFile(file),
name: file.name || 'file',
}));
}
export function filterAudioFilePaths(paths: string[]): string[] {
return paths.filter((path) => AUDIO_EXTENSIONS.has(fileExtension(path)));
}
export function filterMaterialImagePaths(paths: string[]): string[] {
return paths.filter((path) => MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path)));
}
export function pickFirstMaterialImagePath(paths: string[]): string | null {
for (const path of paths) {
if (MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path))) return path;
}
return null;
}
export function pickFirstPreviewFilePath(paths: string[]): string | null {
for (const path of paths) {
if (PREVIEW_EXTENSIONS.has(fileExtension(path))) return path;
}
return null;
}
export function isPreviewMediaPath(filePath: string): boolean {
return PREVIEW_EXTENSIONS.has(fileExtension(filePath));
}
export function sceneTitleFromMediaPath(filePath: string): string {
const base = filePath.split(/[/\\]/).pop() ?? filePath;
const dot = base.lastIndexOf('.');
const title = (dot > 0 ? base.slice(0, dot) : base).trim();
return title.length > 0 ? title : 'Новая сцена';
}
export function partitionSceneMediaDrops(entries: DroppedFileEntry[]): {
accepted: DroppedFileEntry[];
rejected: SceneMediaDropRejected[];
} {
const accepted: DroppedFileEntry[] = [];
const rejected: SceneMediaDropRejected[] = [];
for (const entry of entries) {
if (!entry.path) {
rejected.push({ name: entry.name, reason: 'no_path' });
continue;
}
if (!isPreviewMediaPath(entry.path)) {
rejected.push({ name: entry.name, reason: 'unsupported' });
continue;
}
accepted.push(entry);
}
return { accepted, rejected };
}
function dragHasFiles(e: DragEvent): boolean {
const types = Array.from(e.dataTransfer?.types ?? []);
// Внутренний drag карточки сцены (в т.ч. с превью-картинкой) не должен считаться файловым.
if (types.some((t) => t === 'application/x-dnd-scene-id')) return false;
return types.includes('Files');
}
type UseFileDropZoneOptions = {
disabled?: boolean;
/** Синхронная блокировка (например, идёт reorder списка сцен). */
isBlocked?: () => boolean;
onDropPaths?: (paths: string[]) => void;
onDropEntries?: (entries: DroppedFileEntry[]) => void;
filterPaths?: (paths: string[]) => string[];
};
export function useFileDropZone({
disabled = false,
isBlocked,
onDropPaths,
onDropEntries,
filterPaths,
}: UseFileDropZoneOptions) {
const [dragOver, setDragOver] = useState(false);
const depthRef = useRef(0);
const blocked = useCallback(() => disabled || Boolean(isBlocked?.()), [disabled, isBlocked]);
useEffect(() => {
if (!blocked()) return;
depthRef.current = 0;
setDragOver(false);
}, [blocked]);
const onDragEnter = useCallback(
(e: DragEvent) => {
if (blocked() || !dragHasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
depthRef.current += 1;
setDragOver(true);
},
[blocked],
);
const onDragLeave = useCallback(
(e: DragEvent) => {
if (blocked()) return;
e.preventDefault();
e.stopPropagation();
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) setDragOver(false);
},
[blocked],
);
const onDragOver = useCallback(
(e: DragEvent) => {
if (blocked() || !dragHasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
},
[blocked],
);
const onDrop = useCallback(
(e: DragEvent) => {
if (blocked() || !dragHasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
depthRef.current = 0;
setDragOver(false);
if (onDropEntries) {
const entries = getDroppedFileEntries(e);
if (entries.length === 0) return;
onDropEntries(entries);
return;
}
const raw = getDroppedFilePaths(e);
const paths = filterPaths ? filterPaths(raw) : raw;
if (paths.length === 0) return;
onDropPaths?.(paths);
},
[blocked, filterPaths, onDropEntries, onDropPaths],
);
return { dragOver, onDragEnter, onDragLeave, onDragOver, onDrop };
}
@@ -8,6 +8,12 @@
height: 8px;
}
.handleSide {
background: var(--side-story-handle);
width: 8px;
height: 8px;
}
.card {
box-sizing: border-box;
width: 100%;
@@ -26,6 +32,13 @@
0 25px 50px -12px rgba(167, 139, 250, 0.12);
}
.cardActiveSide {
border-color: rgba(0, 120, 212, 0.95);
box-shadow:
0 0 0 2px rgba(0, 120, 212, 0.35),
0 25px 50px -12px rgba(0, 120, 212, 0.12);
}
.previewShell {
position: relative;
width: 100%;
@@ -65,6 +78,21 @@
box-shadow: var(--shadow-start-badge);
}
.badgeSideStory {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
font-size: 8.5px;
font-weight: 800;
letter-spacing: 0.4px;
padding: 4px 8px;
border-radius: 8px;
background: var(--side-story-fill-solid);
color: var(--text-on-accent);
box-shadow: var(--shadow-side-story-badge);
}
.cornerBadges {
position: absolute;
top: 8px;
@@ -119,6 +147,7 @@
font-size: 20px;
line-height: 1.2;
letter-spacing: -0.02em;
min-width: 0;
}
.musicParams {
+183 -42
View File
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
import { createPortal } from 'react-dom';
import ReactFlow, {
Background,
ConnectionMode,
Handle,
MarkerType,
Panel,
@@ -19,8 +20,15 @@ import ReactFlow, {
import 'reactflow/dist/style.css';
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
import {
canSetSideStoryStart,
isNodeInSideStoryline,
isSideStoryEdge,
} from '../../../shared/graph/sceneGraphLineage';
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
import { RotatedImage } from '../../shared/RotatedImage';
import { EllipsisText } from '../../shared/ui/EllipsisText';
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
import { useAssetUrl } from '../../shared/useAssetImageUrl';
import styles from './SceneGraph.module.css';
@@ -53,6 +61,7 @@ const SCENE_CARD_H = 248;
/** UI strings for the scene graph (passed from editor i18n). */
export type SceneGraphUiStrings = {
badgeStart: string;
badgeSideStory: string;
untitled: string;
videoBadge: string;
audioBadge: string;
@@ -67,11 +76,15 @@ export type SceneGraphUiStrings = {
closeMenu: string;
startScene: string;
unsetStartScene: string;
sideStoryStartScene: string;
unsetSideStoryStartScene: string;
runFromScene: string;
delete: string;
};
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
badgeStart: 'НАЧАЛО',
badgeSideStory: 'ПОБОЧНАЯ',
untitled: 'Без названия',
videoBadge: 'Видео',
audioBadge: 'Аудио',
@@ -86,6 +99,9 @@ const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
closeMenu: 'Закрыть меню',
startScene: 'Начальная сцена',
unsetStartScene: 'Снять метку «Начальная сцена»',
sideStoryStartScene: 'Начальная сцена побочной линии',
unsetSideStoryStartScene: 'Снять метку «Начальная сцена побочной линии»',
runFromScene: 'Запустить с этой сцены',
delete: 'Удалить',
};
@@ -95,15 +111,18 @@ export type SceneGraphProps = {
sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[];
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
currentSceneId: SceneId | null;
/** Выделенная карточка на графе (одна нода, не все копии сцены). */
selectedGraphNodeId: GraphNodeId | null;
graphUi?: SceneGraphUiStrings;
onCurrentSceneChange: (id: SceneId) => void;
onGraphNodeSelect: (graphNodeId: GraphNodeId, sceneId: SceneId) => void;
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
onDisconnect: (edgeId: string) => void;
onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void;
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
onSetGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => void;
onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void;
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
};
@@ -117,6 +136,8 @@ type SceneCardData = {
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
isStartScene: boolean;
isSideStoryStart: boolean;
isSideStoryNode: boolean;
hasSceneAudio: boolean;
previewIsVideo: boolean;
hasAnyAudioLoop: boolean;
@@ -176,15 +197,24 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
const ui = useContext(GraphUiContext);
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
const previewUrl = useAssetUrl(data.previewAssetId);
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
const cardClass = [
styles.card,
data.active ? (data.isSideStoryNode ? styles.cardActiveSide : styles.cardActive) : '',
]
.filter(Boolean)
.join(' ');
const handleClass = data.isSideStoryNode ? styles.handleSide : styles.handle;
const showCornerVideo = data.previewIsVideo;
const showCornerAudio = data.hasSceneAudio;
return (
<div className={styles.nodeWrap}>
<Handle type="target" position={Position.Top} className={styles.handle} />
<Handle type="target" position={Position.Top} className={handleClass} />
<div className={cardClass}>
<div className={styles.previewShell}>
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
{data.isSideStoryStart ? (
<div className={styles.badgeSideStory}>{ui.badgeSideStory}</div>
) : null}
{thumbUrl ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
@@ -265,7 +295,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
) : null}
</div>
<div className={styles.nodeBody}>
<div className={styles.title}>{data.title || ui.untitled}</div>
<EllipsisText text={data.title || ui.untitled} className={[styles.title, ellipsisStyles.root].join(' ')} />
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
<div className={styles.musicParams}>
{data.hasAnyAudioLoop ? (
@@ -300,7 +330,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
) : null}
</div>
</div>
<Handle type="source" position={Position.Bottom} className={styles.handle} />
<Handle type="source" position={Position.Bottom} className={handleClass} />
</div>
);
}
@@ -350,39 +380,66 @@ function SceneGraphCanvas({
sceneGraphNodes,
sceneGraphEdges,
sceneCardById,
currentSceneId,
selectedGraphNodeId,
graphUi,
onCurrentSceneChange,
onGraphNodeSelect,
onConnect,
onDisconnect,
onNodePositionCommit,
onRemoveGraphNodes,
onRemoveGraphNode,
onSetGraphNodeStart,
onSetGraphNodeSideStoryStart,
onRunFromGraphNode,
onDropSceneFromList,
}: SceneGraphProps) {
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
const { screenToFlowPosition } = useReactFlow();
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
const [edgeMenu, setEdgeMenu] = useState<{ x: number; y: number; edgeId: string } | null>(null);
useEffect(() => {
if (!menu) return;
if (!menu && !edgeMenu) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
if (e.key === 'Escape') {
setMenu(null);
setEdgeMenu(null);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [menu]);
}, [edgeMenu, menu]);
const menuNodeIsStart = useMemo(() => {
if (!menu) return false;
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene);
}, [menu, sceneGraphNodes]);
const menuNodeIsSideStoryStart = useMemo(() => {
if (!menu) return false;
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isSideStoryStart);
}, [menu, sceneGraphNodes]);
const menuCanSetSideStoryStart = useMemo(() => {
if (!menu) return false;
return canSetSideStoryStart(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
}, [menu, sceneGraphEdges, sceneGraphNodes]);
const menuNodeIsSideBranch = useMemo(() => {
if (!menu) return false;
return isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
}, [menu, sceneGraphEdges, sceneGraphNodes]);
const sideStoryEdgeStroke = 'rgba(0,120,212,0.95)';
const sideStoryEdgeStrokeDim = 'rgba(0,120,212,0.55)';
const mainEdgeStroke = 'rgba(167,139,250,0.95)';
const mainEdgeStrokeDim = 'rgba(167,139,250,0.55)';
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
return sceneGraphNodes.map((gn) => {
const c = sceneCardById[gn.sceneId];
const active = gn.sceneId === currentSceneId;
const active = selectedGraphNodeId === gn.id;
const isSideStoryNode = isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, gn.id);
const audios = c?.audios ?? [];
return {
id: gn.id,
@@ -398,6 +455,8 @@ function SceneGraphCanvas({
previewVideoAutostart: c?.previewVideoAutostart ?? false,
previewRotationDeg: c?.previewRotationDeg ?? 0,
isStartScene: gn.isStartScene,
isSideStoryStart: gn.isSideStoryStart,
isSideStoryNode,
hasSceneAudio: audios.length >= 1,
previewIsVideo: c?.previewAssetType === 'video',
hasAnyAudioLoop: audios.some((a) => a.loop),
@@ -408,39 +467,46 @@ function SceneGraphCanvas({
style: { padding: 0, background: 'transparent', border: 'none' },
};
});
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
}, [sceneCardById, sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
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) => ({
const hasSelection = selectedGraphNodeId != null;
return sceneGraphEdges.map((e) => {
const isSide = isSideStoryEdge(sceneGraphNodes, sceneGraphEdges, e);
const strokeActive = isSide ? sideStoryEdgeStroke : mainEdgeStroke;
const strokeIdle = isSide ? sideStoryEdgeStrokeDim : mainEdgeStrokeDim;
const strokeDim = 'rgba(255,255,255,0.10)';
const markerDim = 'rgba(255,255,255,0.18)';
const touchesSelection =
selectedGraphNodeId != null &&
(e.sourceGraphNodeId === selectedGraphNodeId || e.targetGraphNodeId === selectedGraphNodeId);
return {
...(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: touchesSelection
? { stroke: strokeActive, strokeWidth: 3 }
: { stroke: strokeDim, strokeWidth: 2 },
markerEnd: touchesSelection
? { type: MarkerType.ArrowClosed, color: strokeActive, strokeWidth: 2 }
: { type: MarkerType.ArrowClosed, color: markerDim, 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 },
style: { stroke: strokeIdle, strokeWidth: 2 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: isSide ? 'rgba(0,120,212,0.85)' : 'rgba(167,139,250,0.85)',
strokeWidth: 2,
},
}),
id: e.id,
source: e.sourceGraphNodeId,
target: e.targetGraphNodeId,
type: 'smoothstep',
animated: false,
}));
}, [currentSceneId, sceneGraphEdges, sceneGraphNodes]);
selectable: false,
};
});
}, [sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
@@ -485,12 +551,22 @@ function SceneGraphCanvas({
if (!menu) return null;
const pad = 8;
const mw = 220;
const mh = 120;
const mh = 210;
const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad));
const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad));
return { x, y };
}, [menu]);
const edgeMenuPosition = useMemo(() => {
if (!edgeMenu) return null;
const pad = 8;
const mw = 200;
const mh = 48;
const x = Math.max(pad, Math.min(edgeMenu.x, window.innerWidth - mw - pad));
const y = Math.max(pad, Math.min(edgeMenu.y, window.innerHeight - mh - pad));
return { x, y };
}, [edgeMenu]);
return (
<GraphUiContext.Provider value={ui}>
<div className={styles.canvasWrap}>
@@ -504,33 +580,35 @@ function SceneGraphCanvas({
}}
onEdgesChange={onEdgesChange}
isValidConnection={isValidConnection}
connectionMode={ConnectionMode.Loose}
onConnect={onConnectInternal}
onEdgesDelete={(eds) => {
for (const ed of eds) {
onDisconnect(ed.id);
}
}}
onEdgeClick={(_, edge) => {
onDisconnect(edge.id);
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
setMenu(null);
setEdgeMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
}}
onNodesDelete={(nds) => {
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
}}
onNodeClick={(_, node) => {
setMenu(null);
setEdgeMenu(null);
const d = node.data as SceneCardData;
onCurrentSceneChange(d.sceneId);
onGraphNodeSelect(node.id as GraphNodeId, d.sceneId);
}}
onNodeContextMenu={(e, node) => {
e.preventDefault();
setEdgeMenu(null);
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
}}
onPaneClick={() => {
setMenu(null);
setEdgeMenu(null);
}}
onPaneContextMenu={(e) => {
e.preventDefault();
setMenu(null);
setEdgeMenu(null);
}}
onInit={(instance) => {
instance.fitView({ padding: 0.25 });
@@ -539,6 +617,7 @@ function SceneGraphCanvas({
onDrop={onDrop}
panOnScroll
selectionOnDrag={false}
minZoom={0.1}
deleteKeyCode={['Backspace', 'Delete']}
proOptions={{ hideAttribution: true }}
>
@@ -579,6 +658,33 @@ function SceneGraphCanvas({
>
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
</button>
{menuNodeIsSideStoryStart || menuCanSetSideStoryStart ? (
<button
type="button"
role="menuitem"
className={styles.ctxItem}
onClick={() => {
onSetGraphNodeSideStoryStart(menu.graphNodeId);
setMenu(null);
}}
>
{menuNodeIsSideStoryStart ? ui.unsetSideStoryStartScene : ui.sideStoryStartScene}
</button>
) : null}
{!menuNodeIsSideBranch ? (
<button
type="button"
role="menuitem"
className={styles.ctxItem}
disabled={!onRunFromGraphNode}
onClick={() => {
onRunFromGraphNode?.(menu.graphNodeId);
setMenu(null);
}}
>
{ui.runFromScene}
</button>
) : null}
<button
type="button"
role="menuitem"
@@ -595,6 +701,41 @@ function SceneGraphCanvas({
document.body,
)
: null}
{edgeMenu && edgeMenuPosition
? createPortal(
<>
<button
type="button"
aria-label={ui.closeMenu}
className={styles.menuBackdrop}
onClick={() => setEdgeMenu(null)}
/>
<div
role="menu"
tabIndex={-1}
className={styles.ctxMenu}
style={{ left: edgeMenuPosition.x, top: edgeMenuPosition.y }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') setEdgeMenu(null);
}}
>
<button
type="button"
role="menuitem"
className={styles.ctxItemDanger}
onClick={() => {
onDisconnect(edgeMenu.edgeId);
setEdgeMenu(null);
}}
>
{ui.delete}
</button>
</div>
</>,
document.body,
)
: null}
</div>
</GraphUiContext.Provider>
);
@@ -19,8 +19,12 @@ function minimalProject(overrides: Partial<Project>): Project {
schemaVersion: 1 as unknown as Project['meta']['schemaVersion'],
},
scenes: {},
sceneListOrder: [],
assets: {},
campaignAudios: [],
materials: [],
npcs: [],
npcRelations: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
function readSceneGraph(): string {
return fs.readFileSync(path.join(here, 'SceneGraph.tsx'), 'utf8');
}
void test('SceneGraph: контекстное меню узла — «Запустить с этой сцены»', () => {
const src = readSceneGraph();
assert.ok(src.includes('runFromScene'));
assert.ok(src.includes('onRunFromGraphNode'));
assert.ok(src.includes('onRunFromGraphNode?.(menu.graphNodeId)'));
});
void test('SceneGraph: побочная линия — меню старта и скрытие Run', () => {
const src = readSceneGraph();
assert.ok(src.includes('sideStoryStartScene'));
assert.ok(src.includes('onSetGraphNodeSideStoryStart'));
assert.ok(src.includes('menuNodeIsSideBranch'));
assert.ok(src.includes('badgeSideStory'));
});
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { HelpSectionId } from './helpSections';
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
const RU_TITLES: Record<HelpSectionId, string> = {
overview: 'Обзор приложения',
license: 'Лицензия и первый запуск',
projects: 'Проекты',
scenes: 'Сцены',
graph: 'Граф сцен',
sideStorylines: 'Побочные сюжетные линии',
sceneProps: 'Свойства сцены',
sceneEditor: 'Редактор сцены',
grid: 'Генератор сетки',
traps: 'Ловушки',
tokens: 'Неигровые токены',
campaignAudio: 'Аудио игры',
materials: 'Материалы',
players: 'Игроки',
npcs: 'НПС',
session: 'Запуск сессии',
controlPanel: 'Пульт управления',
transitions: 'Переходы между сценами',
music: 'Музыка на пульте',
effects: 'Эффекты поля и действий',
presentation: 'Экран презентации',
importExport: 'Импорт и экспорт',
settings: 'Настройки, язык и обновления',
};
void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => {
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
const text = 'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
const ranges = findHelpLinkRanges(text, catalog);
assert.deepEqual(
ranges.map((r) => r.id),
['traps', 'effects', 'grid', 'tokens'],
);
});
void test('findHelpLinkRanges: длинный title предпочитается короткому алиасу', () => {
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
const text = 'Откройте «Редактор сцены» и «Генератор сетки».';
const ranges = findHelpLinkRanges(text, catalog);
assert.equal(ranges.length, 2);
assert.equal(ranges[0]?.id, 'sceneEditor');
assert.equal(ranges[1]?.id, 'grid');
});
void test('splitHelpTextWithLinks: EN see Scene editor / Traps', () => {
const getTitle = (id: HelpSectionId): string => {
const map: Partial<Record<HelpSectionId, string>> = {
sceneEditor: 'Scene editor',
traps: 'Traps',
tokens: 'Non-player tokens',
grid: 'Grid generator',
effects: 'Field and action effects',
controlPanel: 'Control panel',
};
return map[id] ?? id;
};
const catalog = buildHelpLinkCatalog(getTitle);
const parts = splitHelpTextWithLinks(
'For details, see Grid generator, Traps, and Non-player tokens. Also see Effects.',
catalog,
);
const links = parts.filter((p) => p.type === 'link');
assert.deepEqual(
links.map((p) => (p.type === 'link' ? p.id : null)),
['grid', 'traps', 'tokens', 'effects'],
);
});
void test('findHelpLinkRanges: полное «Пульт управления» не режется до «Пульт»', () => {
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
const ranges = findHelpLinkRanges('см. «Пульт управления»', catalog);
assert.equal(ranges.length, 1);
assert.equal(ranges[0]?.id, 'controlPanel');
assert.equal(ranges[0]?.text, '«Пульт управления»');
});
+116
View File
@@ -0,0 +1,116 @@
import { HELP_SECTION_IDS, type HelpSectionId } from './helpSections';
/**
* Короткие имена разделов в перекрёстных ссылках
* (когда в тексте не полное title, напр. «Эффекты» вместо «Эффекты поля и действий»).
*/
export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly string[]>> = {
effects: ['Эффекты', 'Effects'],
presentation: ['Презентация', 'Presentation'],
grid: ['Сетка', 'Grid'],
controlPanel: ['Пульт'],
players: ['Игроки', 'Players'],
};
export type HelpLinkCatalogEntry = {
id: HelpSectionId;
label: string;
};
export type HelpLinkRange = {
start: number;
end: number;
id: HelpSectionId;
text: string;
};
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Каталог подписей → id, длинные первыми. */
export function buildHelpLinkCatalog(getTitle: (id: HelpSectionId) => string): HelpLinkCatalogEntry[] {
const byLabel = new Map<string, HelpSectionId>();
for (const id of HELP_SECTION_IDS) {
const title = getTitle(id).trim();
if (title) byLabel.set(title, id);
for (const alias of HELP_SECTION_LINK_ALIASES[id] ?? []) {
const a = alias.trim();
if (a) byLabel.set(a, id);
}
}
return [...byLabel.entries()]
.map(([label, id]) => ({ id, label }))
.sort((a, b) => b.label.length - a.label.length || a.label.localeCompare(b.label));
}
function isBoundaryChar(ch: string | undefined): boolean {
if (ch === undefined) return true;
if (/^[\p{L}\p{N}]$/u.test(ch)) return false;
return true;
}
function collectMatches(text: string, label: string, id: HelpSectionId): HelpLinkRange[] {
const out: HelpLinkRange[] = [];
if (!label) return out;
const quoted = new RegExp(`«${escapeRegExp(label)}»`, 'g');
for (const m of text.matchAll(quoted)) {
if (m.index === undefined) continue;
out.push({ start: m.index, end: m.index + m[0].length, id, text: m[0] });
}
const bare = new RegExp(escapeRegExp(label), 'g');
for (const m of text.matchAll(bare)) {
if (m.index === undefined) continue;
const start = m.index;
const end = start + m[0].length;
const before = text[start - 1];
const after = text[end];
if (!isBoundaryChar(before) || !isBoundaryChar(after)) continue;
if (before === '«' && after === '»') continue;
out.push({ start, end, id, text: m[0] });
}
return out;
}
export function findHelpLinkRanges(text: string, catalog: readonly HelpLinkCatalogEntry[]): HelpLinkRange[] {
const candidates: HelpLinkRange[] = [];
for (const entry of catalog) {
candidates.push(...collectMatches(text, entry.label, entry.id));
}
candidates.sort(
(a, b) => a.start - b.start || b.end - b.start - (a.end - a.start),
);
const selected: HelpLinkRange[] = [];
let cursor = 0;
for (const range of candidates) {
if (range.start < cursor) continue;
selected.push(range);
cursor = range.end;
}
return selected;
}
export function splitHelpTextWithLinks(
text: string,
catalog: readonly HelpLinkCatalogEntry[],
): Array<{ type: 'text'; value: string } | { type: 'link'; id: HelpSectionId; value: string }> {
const ranges = findHelpLinkRanges(text, catalog);
if (ranges.length === 0) return [{ type: 'text', value: text }];
const parts: Array<{ type: 'text'; value: string } | { type: 'link'; id: HelpSectionId; value: string }> =
[];
let cursor = 0;
for (const range of ranges) {
if (range.start > cursor) {
parts.push({ type: 'text', value: text.slice(cursor, range.start) });
}
parts.push({ type: 'link', id: range.id, value: range.text });
cursor = range.end;
}
if (cursor < text.length) {
parts.push({ type: 'text', value: text.slice(cursor) });
}
return parts;
}
+42
View File
@@ -0,0 +1,42 @@
/** Порядок разделов в окне «Инструкция». */
import { USERS_BRANCH_FEATURES_ENABLED } from '../../../shared/features/usersBranchFeatures';
const HELP_SECTION_IDS_ALL = [
'overview',
'license',
'projects',
'scenes',
'graph',
'sideStorylines',
'sceneProps',
'sceneEditor',
'grid',
'traps',
'tokens',
'campaignAudio',
'materials',
'players',
'npcs',
'session',
'controlPanel',
'transitions',
'music',
'effects',
'presentation',
'importExport',
'settings',
] as const;
export type HelpSectionId = (typeof HELP_SECTION_IDS_ALL)[number];
export const HELP_SECTION_IDS: readonly HelpSectionId[] = USERS_BRANCH_FEATURES_ENABLED
? HELP_SECTION_IDS_ALL
: HELP_SECTION_IDS_ALL.filter((id) => id !== 'players');
export function helpSectionTitleKey(id: HelpSectionId): string {
return `help.section.${id}.title`;
}
export function helpSectionBodyKey(id: HelpSectionId): string {
return `help.section.${id}.body`;
}
+22 -1
View File
@@ -1,4 +1,4 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
EDITOR_LOCALE_STORAGE_KEY,
@@ -7,6 +7,8 @@ import {
translateEditorMessage,
type EditorLocale,
} from './editorMessages';
import { getDndApi } from '../../shared/dndApi';
import { ipcChannels } from '../../../shared/ipc/contracts';
type EditorI18nContextValue = {
locale: EditorLocale;
@@ -36,6 +38,25 @@ export function EditorI18nProvider({ children }: { children: React.ReactNode })
}
}, []);
useEffect(() => {
const tag = locale === 'ru' ? 'ru-RU' : 'en-US';
try {
void getDndApi().invoke(ipcChannels.windows.syncChromeTitles, { localeTag: tag });
} catch {
// preload ещё не готов (редко при первом кадре)
}
}, [locale]);
// Другие окна Electron (пульт, материалы) подхватывают смену языка из редактора.
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key !== EDITOR_LOCALE_STORAGE_KEY) return;
setLocaleState(normalizeEditorLocale(e.newValue));
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const t = useCallback(
(key: string, vars?: Record<string, string | number>) => translateEditorMessage(locale, key, vars),
[locale],
@@ -1,7 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { inferEditorLocaleFromSystem, normalizeEditorLocale } from './editorMessages';
import { HELP_SECTION_IDS, helpSectionBodyKey, helpSectionTitleKey } from '../help/helpSections';
import { EDITOR_MESSAGES, inferEditorLocaleFromSystem, normalizeEditorLocale } from './editorMessages';
void test('inferEditorLocaleFromSystem: en-* wins when listed first', () => {
assert.equal(inferEditorLocaleFromSystem(['en-GB', 'ru-RU']), 'en');
@@ -24,7 +26,45 @@ void test('normalizeEditorLocale: trims stored en/ru', () => {
assert.equal(normalizeEditorLocale('ru '), 'ru');
});
void test('normalizeEditorLocale: blank or invalid defers to infer (explicit list)', () => {
assert.equal(normalizeEditorLocale(''), inferEditorLocaleFromSystem([]));
assert.equal(normalizeEditorLocale('xx'), inferEditorLocaleFromSystem([]));
void test('normalizeEditorLocale: blank or invalid defers to system infer', () => {
const inferred = inferEditorLocaleFromSystem();
assert.equal(normalizeEditorLocale(''), inferred);
assert.equal(normalizeEditorLocale('xx'), inferred);
});
void test('EDITOR_MESSAGES: ru and en have the same keys', () => {
const ruKeys = Object.keys(EDITOR_MESSAGES.ru).sort();
const enKeys = Object.keys(EDITOR_MESSAGES.en).sort();
assert.deepEqual(enKeys, ruKeys);
});
void test('EDITOR_MESSAGES: every help section has title and body in both locales', () => {
for (const id of HELP_SECTION_IDS) {
const title = helpSectionTitleKey(id);
const body = helpSectionBodyKey(id);
for (const locale of ['ru', 'en'] as const) {
assert.ok(EDITOR_MESSAGES[locale][title], `missing ${locale} ${title}`);
assert.ok(EDITOR_MESSAGES[locale][body], `missing ${locale} ${body}`);
assert.notEqual(EDITOR_MESSAGES[locale][title]!.trim(), '');
assert.notEqual(EDITOR_MESSAGES[locale][body]!.trim(), '');
}
}
});
void test('EDITOR_MESSAGES: materials.* keys exist in both locales', () => {
const materialKeys = Object.keys(EDITOR_MESSAGES.ru).filter((k) => k.startsWith('materials.'));
assert.ok(materialKeys.length >= 20, `expected materials.* keys, got ${String(materialKeys.length)}`);
for (const key of materialKeys) {
assert.ok(EDITOR_MESSAGES.en[key], `missing en ${key}`);
assert.notEqual(EDITOR_MESSAGES.en[key]!.trim(), '');
}
});
void test('EDITOR_MESSAGES: npcs.* keys exist in both locales', () => {
const npcKeys = Object.keys(EDITOR_MESSAGES.ru).filter((k) => k.startsWith('npcs.'));
assert.ok(npcKeys.length >= 20, `expected npcs.* keys, got ${String(npcKeys.length)}`);
for (const key of npcKeys) {
assert.ok(EDITOR_MESSAGES.en[key], `missing en ${key}`);
assert.notEqual(EDITOR_MESSAGES.en[key]!.trim(), '');
}
});
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More