Merge branch 'users' into main

Players library, circular NPC tokens, disposition types; keep main overlay/release fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-04 08:03:22 +08:00
69 changed files with 5943 additions and 327 deletions
+62
View File
@@ -59,6 +59,68 @@
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;
+141 -51
View File
@@ -55,6 +55,8 @@ import type { HelpSectionId } from './help/helpSections';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState';
@@ -134,9 +136,9 @@ export function EditorApp() {
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
const [importNpcConflicts, setImportNpcConflicts] = useState<
ReturnType<typeof computeNpcImportConflicts>
>([]);
const [importNpcConflicts, setImportNpcConflicts] = useState<ReturnType<typeof computeNpcImportConflicts>>(
[],
);
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false);
@@ -154,6 +156,11 @@ export function EditorApp() {
const licenseActive = licenseSnap?.active === true;
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
const runSplitRef = useRef<HTMLDivElement | null>(null);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => {
@@ -351,13 +358,17 @@ export function EditorApp() {
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId) => {
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return;
setLaunching(true);
setRunMenuOpen(false);
setLaunchPlayersOpen(false);
void (async () => {
try {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {
...(playerIds && playerIds.length > 0 ? { playerIds } : {}),
});
} catch {
setLaunching(false);
}
@@ -461,6 +472,23 @@ export function EditorApp() {
return () => window.removeEventListener('mousedown', onDown);
}, [aboutMenuOpen]);
useEffect(() => {
if (!runMenuOpen) return;
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) setRunMenuPos({ left: r.right, top: r.bottom + 8 });
else setRunMenuPos(null);
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-runmenu-root="1"]')) return;
setRunMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [runMenuOpen]);
useEffect(() => {
let off: (() => void) | null = null;
void (async () => {
@@ -546,12 +574,7 @@ export function EditorApp() {
sceneResolutions,
npcResolutions,
)
: await actions.mergeImportZip(
importPeek.filePath!,
selections,
sceneResolutions,
npcResolutions,
);
: await actions.mergeImportZip(importPeek.filePath!, selections, sceneResolutions, npcResolutions);
setImportReport(report);
setImportReportOpen(true);
clearImportFlow();
@@ -571,12 +594,7 @@ export function EditorApp() {
setImportNpcConflictsOpen(true);
return;
}
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
[],
[],
);
const npcResolutions = buildNpcResolutionsForImport(state.project, importPeek.sourceProject, [], []);
void runStorylineMerge(selections, sceneResolutions, npcResolutions);
},
[importPeek, runStorylineMerge, state.project],
@@ -724,9 +742,7 @@ export function EditorApp() {
{t('scenes.batchProgress')
.replace('{current}', String(state.sceneBatchImport.current))
.replace('{total}', String(state.sceneBatchImport.total))}
{state.sceneBatchImport.fileName
? `: ${state.sceneBatchImport.fileName}`
: ''}
{state.sceneBatchImport.fileName ? `: ${state.sceneBatchImport.fileName}` : ''}
</div>
<div>
{Math.round(
@@ -825,6 +841,23 @@ export function EditorApp() {
{t('top.file')}
</button>
) : null}
<button
type="button"
data-testid="players-header-btn"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen(false);
setPlayersManagerOpen(true);
}}
>
{t('top.players')}
</button>
</div>
<div className={styles.flex1} />
{appVersionText ? (
@@ -835,16 +868,55 @@ export function EditorApp() {
<div className={styles.headerActions}>
{state.project ? (
<>
<Button
variant="primary"
disabled={runDisabled || launching}
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId || launching) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
<div
ref={runSplitRef}
className={styles.splitRun}
data-runmenu-root="1"
aria-disabled={runDisabled || launching ? true : undefined}
>
{t('top.run')}
</Button>
<button
type="button"
className={styles.splitRunMain}
disabled={runDisabled || launching}
data-testid="run-main-btn"
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId || launching) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
>
{t('top.run')}
</button>
<span className={styles.splitRunDivider} aria-hidden />
<button
type="button"
className={[
styles.splitRunChevron,
runMenuOpen ? styles.splitRunChevronOpen : '',
]
.filter(Boolean)
.join(' ')}
disabled={runDisabled || launching}
aria-label={t('top.runMenuAria')}
aria-haspopup="menu"
aria-expanded={runMenuOpen}
data-testid="run-menu-btn"
onClick={() => {
if (runDisabled || launching) return;
setRunMenuOpen((v) => !v);
}}
>
<svg className={styles.splitRunChevronSvg} viewBox="0 0 12 12" aria-hidden>
<path
d="M2.5 4.25 L6 7.75 L9.5 4.25"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
{runDisabled ? (
<Button
variant="ghost"
@@ -900,9 +972,7 @@ export function EditorApp() {
scene={s}
reorderEnabled={sceneListReorderEnabled}
listDragActive={draggingListSceneId !== null}
dropPlace={
sceneListDrop?.targetId === s.id ? sceneListDrop.place : null
}
dropPlace={sceneListDrop?.targetId === s.id ? sceneListDrop.place : null}
isDragging={draggingListSceneId === s.id}
onSelect={() => {
setSelectedGraphNodeId(null);
@@ -925,9 +995,7 @@ export function EditorApp() {
return;
}
setSceneListDrop((cur) =>
cur?.targetId === targetId && cur.place === place
? cur
: { targetId, place },
cur?.targetId === targetId && cur.place === place ? cur : { targetId, place },
);
}}
onDropListReorder={(draggedId, targetId, place) => {
@@ -1552,6 +1620,39 @@ export function EditorApp() {
}}
/>
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
<LaunchPlayersModal
open={launchPlayersOpen}
onClose={() => setLaunchPlayersOpen(false)}
onConfirm={(playerIds) => {
if (!graphStartGraphNodeId) return;
launchFromGraphNode(graphStartGraphNodeId, playerIds);
}}
/>
{runMenuOpen && runMenuPos
? createPortal(
<div
data-runmenu-root="1"
className={styles.fileMenu}
style={{ left: runMenuPos.left, top: runMenuPos.top, transform: 'translateX(-100%)' }}
role="menu"
>
<button
type="button"
className={styles.fileMenuItem}
role="menuitem"
data-testid="run-with-players-item"
onClick={() => {
setRunMenuOpen(false);
setLaunchPlayersOpen(true);
}}
>
{t('top.runWithPlayers')}
</button>
</div>,
document.body,
)
: null}
<MaterialsManagerModal
open={materialsManagerOpen}
materials={state.project?.materials ?? []}
@@ -2379,9 +2480,7 @@ function CampaignInspector({
onDragOver={audioDrop.onDragOver}
onDrop={audioDrop.onDrop}
>
{audioDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null}
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
@@ -2548,10 +2647,7 @@ function SceneInspector({
{sideStoryStartNodes.map((gn) => (
<div key={gn.id}>
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
<Input
value={gn.sideStoryLineTitle}
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
/>
<Input value={gn.sideStoryLineTitle} onChange={(v) => onSideStoryLineTitleChange(gn.id, v)} />
<div className={styles.spacer8} />
</div>
))}
@@ -2567,9 +2663,7 @@ function SceneInspector({
onDragOver={previewDrop.onDragOver}
onDrop={previewDrop.onDrop}
>
{previewDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div>
) : null}
{previewDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div> : null}
{previewUrl && previewAssetType === 'image' ? (
<div className={styles.previewFill}>
<RotatedImage
@@ -2668,9 +2762,7 @@ function SceneInspector({
onDragOver={sceneAudioDrop.onDragOver}
onDrop={sceneAudioDrop.onDrop}
>
{sceneAudioDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null}
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
@@ -2983,9 +3075,7 @@ function SceneListCard({
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>
{t('confirmDeleteScene.body', { name: scene.title })}
</div>
<div className={styles.muted}>{t('confirmDeleteScene.body', { name: scene.title })}</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</Button>
@@ -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,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,
);
}
@@ -170,21 +170,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionBold')}
active={toolbarState.bold}
active={toolbarState?.bold ?? false}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<strong>B</strong>
</ToolButton>
<ToolButton
title={t('scene.descriptionItalic')}
active={toolbarState.italic}
active={toolbarState?.italic ?? false}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<em>I</em>
</ToolButton>
<ToolButton
title={t('scene.descriptionUnderline')}
active={toolbarState.underline}
active={toolbarState?.underline ?? false}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<span style={{ textDecoration: 'underline' }}>U</span>
@@ -194,21 +194,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionHeading2')}
active={toolbarState.h2}
active={toolbarState?.h2 ?? false}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
H2
</ToolButton>
<ToolButton
title={t('scene.descriptionHeading3')}
active={toolbarState.h3}
active={toolbarState?.h3 ?? false}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
H3
</ToolButton>
<ToolButton
title={t('scene.descriptionQuote')}
active={toolbarState.blockquote}
active={toolbarState?.blockquote ?? false}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
@@ -218,14 +218,14 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionBulletList')}
active={toolbarState.bulletList}
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}
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" />
+2 -2
View File
@@ -18,6 +18,7 @@ const RU_TITLES: Record<HelpSectionId, string> = {
tokens: 'Неигровые токены',
campaignAudio: 'Аудио игры',
materials: 'Материалы',
players: 'Игроки',
npcs: 'НПС',
session: 'Запуск сессии',
controlPanel: 'Пульт управления',
@@ -31,8 +32,7 @@ const RU_TITLES: Record<HelpSectionId, string> = {
void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => {
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
const text =
'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
const text = 'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
const ranges = findHelpLinkRanges(text, catalog);
assert.deepEqual(
ranges.map((r) => r.id),
+1
View File
@@ -9,6 +9,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
presentation: ['Презентация', 'Presentation'],
grid: ['Сетка', 'Grid'],
controlPanel: ['Пульт'],
players: ['Игроки', 'Players'],
};
export type HelpLinkCatalogEntry = {
+1
View File
@@ -13,6 +13,7 @@ export const HELP_SECTION_IDS = [
'tokens',
'campaignAudio',
'materials',
'players',
'npcs',
'session',
'controlPanel',
+105 -9
View File
@@ -110,9 +110,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.settings': 'Настройки',
'top.project': 'Проект',
'top.file': 'Файл',
'top.players': 'Игроки',
'top.backToProjects': 'К списку проектов',
'top.appVersion': 'Версия приложения',
'top.run': 'Запустить',
'top.runMenuAria': 'Дополнительные варианты запуска',
'top.runWithPlayers': 'Запустить с игроками',
'top.runWithPlayersTitle': 'Запуск с игроками',
'top.runWithPlayersHint': 'Выберите игроков и/или команды. Выбор сохранится на время сессии.',
'top.runWithPlayersTeams': 'Команды',
'top.runWithPlayersPlayers': 'Игроки',
'top.runWithPlayersEmpty': 'Нет игроков в библиотеке.',
'top.runWithPlayersConfirm': 'Запустить',
'top.launching': 'Запуск…',
'top.afterLicense': 'Доступно после активации лицензии',
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
@@ -175,7 +184,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.sceneEditor.title': 'Редактор сцены',
'help.section.sceneEditor.body':
'«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки и неигровые токены. Доступен только для сцен с изображением (не с видео).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку, если её ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены» и «Ловушки»; справа — карта сцены.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».',
'«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки, неигровые токены и круглые токены НПС. Доступен только для сцен с изображением (не с видео).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку, если её ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены», «НПС» и «Ловушки»; справа — карта сцены. Перетащите НПС на карту, чтобы добавить его круглый токен.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».',
'help.section.grid.title': 'Генератор сетки',
'help.section.grid.body':
@@ -196,14 +205,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.materials.title': 'Материалы',
'help.section.materials.body':
'Материалы — изображения кампании (карты, записки, чертежи), которые можно показать игрокам поверх сцены во время игры. Они общие для проекта и не привязаны к одной сцене.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «Материалы».\n\n2) «Добавить» — укажите уникальное название и изображение (PNG, JPG или WebP): кнопка выбора или перетаскивание файла.\n\n3) В списке можно искать, менять порядок перетаскиванием, править или удалять через меню «⋮» (перед удалением будет подтверждение).\n\n4) Под большим превью — «Повернуть»: поворот на 90° (учитывается и в плитке, и при показе на экране).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» нажмите кнопку материалов (иконка карты сокровищ) — откроется отдельное окно со списком.\n\n2) Клик по плитке показывает материал поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его.\n\n3) На предпросмотре пульта материал можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне материалов лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по материалу в предпросмотре пульта.\n\nПри смене сцены показ материала сбрасывается. Описание сцены и эффекты поля с материалами не связаны.',
'help.section.players.title': 'Игроки',
'help.section.players.body':
'Раздел «Игроки» в шапке хранит локальную библиотеку живых игроков на этом компьютере (не внутри файла проекта).\n\n1) Откройте «Игроки» в шапке.\n\n2) «Добавить игрока» — имя и изображение обязательны. Пока идёт сохранение, видно окно с прогрессом.\n\n3) Справа — превью игрового токена: круг с цветной рамкой, аватар внутри (перетащите мышью, чтобы отцентровать; колесо мыши — увеличить/уменьшить), имя на тёмной подложке и выбор цвета рамки.\n\n4) Команды — плоские группы без вложенности: создайте команду, перетащите игрока в неё или в «Без команды».\n\nКампанийных НПС на сцену ставят отдельно: в «Редактор сцены» аккордеон «НПС» — плитки персонажей проекта, на карте они выглядят как такой же круглый токен.',
'help.section.npcs.title': 'НПС',
'help.section.npcs.body':
'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются).\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи. Связь однонаправленная (А → Б и Б → А — разные). Несколько связей в одном направлении рисуются параллельными дугами. Клик по связи или её подписи выбирает исходного персонажа и подсвечивает его исходящие связи.\n\n5) Справа — карточка выбранного персонажа: аватар, имя, описание (форматированный текст) и список «Отношения» — только исходящие связи («название» + имя цели).\n\n6) Правый клик по связи на графе — «Редактировать» название или «Удалить» (с подтверждением).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» рядом с материалами нажмите кнопку НПС (цветная иконка человека) — откроется отдельное окно.\n\n2) Справа — список персонажей; клик по плитке показывает аватар поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его. Слева — описание и исходящие отношения выбранного персонажа (их видите только вы).\n\n3) На предпросмотре пульта аватар можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне НПС лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по аватару в предпросмотре пульта.\n\nПри смене сцены показ НПС сбрасывается. Игроки на презентации видят только аватар.',
'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются).\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи.\n\n5) Справа — карточка выбранного персонажа: настройте круглый токен, цвет рамки и положение аватара (перетаскивание и колесо мыши для масштаба), а также имя, описание и отношения.\n\n6) В «Редакторе сцены» раскройте «НПС» и перетащите персонажа на карту. Круглый токен можно двигать и менять его размер; во время сессии он доступен на пульте и только отображается в презентации.',
'help.section.session.title': 'Запуск сессии',
'help.section.session.body':
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить демонстрацию» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
'help.section.controlPanel.title': 'Пульт управления',
'help.section.controlPanel.body':
@@ -416,6 +428,28 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'materials.zoomOutHint': 'Кликните по материалу в предпросмотре пульта, чтобы уменьшить.',
'materials.zoomIdleHint': 'Выберите лупу, затем кликните по материалу в предпросмотре пульта.',
'players.managerTitle': 'Игроки',
'players.add': 'Добавить игрока',
'players.search': 'Поиск игроков…',
'players.selectPrompt': 'Выберите игрока или добавьте нового.',
'players.name': 'ИМЯ',
'players.namePlaceholder': 'Имя игрока…',
'players.ringColor': 'ЦВЕТ РАМКИ',
'players.chooseImage': 'Выбрать изображение',
'players.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
'players.savingTitle': 'Сохранение игрока',
'players.savingWait': 'Подождите…',
'players.ungrouped': 'Без команды',
'players.addTeam': 'Новая команда',
'players.teamName': 'Название команды',
'players.teamColor': 'Цвет команды',
'players.teamMenu': 'Меню команды',
'players.dropHere': 'Перетащите игрока сюда',
'players.deleteTitle': 'Удаление игрока',
'players.deleteConfirm': 'Вы уверены, что хотите удалить игрока «{name}»?',
'players.deleteTeamTitle': 'Удаление команды',
'players.deleteTeamConfirm': 'Вы уверены, что хотите удалить команду «{name}»? Игроки останутся без команды.',
'npcs.open': 'НПС',
'npcs.editorTitle': 'НПС',
'npcs.add': 'Добавить',
@@ -440,6 +474,16 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.avatarRequired': 'Выберите аватар.',
'npcs.chooseAvatar': 'Выбрать аватар',
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
'npcs.disposition': 'ТИП',
'npcs.disposition.hostile': 'Враждебный',
'npcs.disposition.neutral': 'Нейтральный',
'npcs.disposition.friendly': 'Дружественный',
'npcs.makeHostile': 'Сделать враждебным',
'npcs.makeNeutral': 'Сделать нейтральным',
'npcs.makeFriendly': 'Сделать дружественным',
'npcs.inactive': 'Неактивен',
'npcs.makeActive': 'Сделать активным',
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
'npcs.description': 'ОПИСАНИЕ',
'npcs.descriptionPlaceholder': 'Описание персонажа…',
'npcs.descriptionEmpty': 'Описание отсутствует',
@@ -578,7 +622,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.passed': 'Пройдено',
'control.noActiveScene': 'Нет активной сцены.',
'control.screenPreview': 'Предпросмотр экрана',
'control.stopPresentation': 'Выключить демонстрацию',
'control.npcTokenScale': 'Размеры игр. токенов',
'control.stopPresentation': 'Выключить',
'control.showPlayers': 'Показать игроков',
'control.hidePlayers': 'Скрыть игроков',
'control.videoBrushHint':
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
'control.branches': 'Варианты ветвления',
@@ -680,9 +727,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.settings': 'Settings',
'top.project': 'Project',
'top.file': 'File',
'top.players': 'Players',
'top.backToProjects': 'Back to projects',
'top.appVersion': 'App version',
'top.run': 'Run',
'top.runMenuAria': 'More launch options',
'top.runWithPlayers': 'Run with players',
'top.runWithPlayersTitle': 'Launch with players',
'top.runWithPlayersHint': 'Select players and/or teams. The selection is kept for this session.',
'top.runWithPlayersTeams': 'Teams',
'top.runWithPlayersPlayers': 'Players',
'top.runWithPlayersEmpty': 'No players in the library.',
'top.runWithPlayersConfirm': 'Launch',
'top.launching': 'Starting…',
'top.afterLicense': 'Available after license activation',
'top.setStartScene': 'Set a start scene on the graph (rightclick a node)',
@@ -745,7 +801,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.sceneEditor.title': 'Scene editor',
'help.section.sceneEditor.body':
'Scene editor is a separate window for preparing the map: battle grid, traps, and non-player tokens. It is available only for image scenes (not video).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left are the Grid, Non-player tokens, and Traps accordions; on the right is the scene map.\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nUnder the accordions, Clear scene removes every trap and token from the current map (it does not delete tokens from the app library).\n\nFor details, see Grid generator, Traps, and Non-player tokens.',
'Scene editor is a separate window for preparing the map: battle grid, traps, non-player tokens, and circular NPC tokens. It is available only for image scenes (not video).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left are the Grid, Non-player tokens, NPCs, and Traps accordions; on the right is the scene map. Drag an NPC onto the map to add its circular token.\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nUnder the accordions, Clear scene removes every trap and token from the current map (it does not delete tokens from the app library).\n\nFor details, see Grid generator, Traps, and Non-player tokens.',
'help.section.grid.title': 'Grid generator',
'help.section.grid.body':
@@ -766,14 +822,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.materials.title': 'Materials',
'help.section.materials.body':
'Materials are campaign images (maps, notes, sketches) you can show players on top of the scene during play. They belong to the project and are not tied to one scene.\n\nIn the editor:\n\n1) Under Game properties, click Materials.\n\n2) Add — enter a unique name and an image (PNG, JPG, or WebP) via Choose image or by dropping a file.\n\n3) In the list you can search, reorder by drag-and-drop, and edit or delete via the ⋮ menu (delete asks for confirmation).\n\n4) Under the large preview, Rotate turns the image by 90° (applied in the tile and when shown on screen).\n\nDuring a session:\n\n1) On the control panel under Tools, click the materials button (treasure-map icon) to open a separate window with the list.\n\n2) Click a tile to show the material over the scene on the control preview and presentation; click the same tile again to hide it.\n\n3) On the control preview you can drag the material and resize it from the corners; the × button closes the overlay.\n\n4) In the materials window, the + / magnifiers are zoom tools: pick one, then click the material on the control preview.\n\nChanging scenes clears the material overlay. Scene description and field effects are separate from materials.',
'help.section.players.title': 'Players',
'help.section.players.body':
'The Players item in the header stores a local library of live players on this computer (not inside the project file).\n\n1) Open Players in the header.\n\n2) Add player — name and image are required. A progress dialog appears while saving.\n\n3) On the right — player token preview: a circle with a colored ring, avatar inside (drag to recenter; mouse wheel to zoom), name on a dark plate, and a ring color picker.\n\n4) Teams are flat groups with no nesting: create a team and drag a player into it or into No team.\n\nCampaign NPCs are placed separately: in Scene editor open the NPCs accordion and drag project characters onto the map — they appear as the same circular token.',
'help.section.npcs.title': 'NPCs',
'help.section.npcs.body':
'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project.\n\nIn the editor:\n\n1) Under Game properties, click NPCs — a separate character editor window opens.\n\n2) Add — enter a unique name and a required avatar (PNG, JPG, or WebP) via Choose avatar or by dropping a file. You can fill in the description right away if you want.\n\n3) Left: character list with search and drag reorder; the ⋮ menu is delete only (with confirmation; all relations involving that character are removed too).\n\n4) Center: relationship graph — drag an arrow from one character to another and enter a required relation name. Relations are one-way (A → B and B → A are different). Multiple relations in the same direction are drawn as parallel curves. Click a relation or its label to select the source character and highlight their outgoing links.\n\n5) Right: the selected characters card — avatar, name, description (rich text), and a Relations list of outgoing links only (“name” + target name).\n\n6) Right-click a relation on the graph to Edit the name or Delete (with confirmation).\n\nDuring a session:\n\n1) On the control panel under Tools, next to materials, click the NPCs button (colored person icon) to open a separate window.\n\n2) Right: character list; click a tile to show the avatar over the scene on the control preview and presentation; click the same tile again to hide it. Left: description and outgoing relations for the selected character (visible only to you).\n\n3) On the control preview you can drag the avatar and resize it from the corners; the × button closes the overlay.\n\n4) In the NPCs window, the + / magnifiers are zoom tools: pick one, then click the avatar on the control preview.\n\nChanging scenes clears the NPC overlay. Players on presentation see only the avatar.',
'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project.\n\nIn the NPC editor, select a character and use the right inspector to configure their circular token: ring color, avatar position (drag), and zoom (mouse wheel) can be adjusted directly in the preview. Name, description, group, and relations remain available there as well.\n\nIn Scene editor, expand NPCs and drag a character onto the map. The circular marker can be moved and resized. During a session it remains movable on the control preview and is read-only on presentation.',
'help.section.session.title': 'Starting a session',
'help.section.session.body':
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Stop presentation or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Turn off or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
'help.section.controlPanel.title': 'Control panel',
'help.section.controlPanel.body':
@@ -987,6 +1046,29 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'materials.zoomOutHint': 'Click the material on the control preview to zoom out.',
'materials.zoomIdleHint': 'Pick a magnifier, then click the material on the control preview.',
'players.managerTitle': 'Players',
'players.add': 'Add player',
'players.search': 'Search players…',
'players.selectPrompt': 'Select a player or add a new one.',
'players.name': 'NAME',
'players.namePlaceholder': 'Player name…',
'players.ringColor': 'RING COLOR',
'players.chooseImage': 'Choose image',
'players.dropHint': 'Drop an image (PNG, JPG, WebP)',
'players.savingTitle': 'Saving player',
'players.savingWait': 'Please wait…',
'players.ungrouped': 'No team',
'players.addTeam': 'New team',
'players.teamName': 'Team name',
'players.teamColor': 'Team color',
'players.teamMenu': 'Team menu',
'players.dropHere': 'Drop a player here',
'players.deleteTitle': 'Delete player',
'players.deleteConfirm': 'Are you sure you want to delete player “{name}”?',
'players.deleteTeamTitle': 'Delete team',
'players.deleteTeamConfirm':
'Are you sure you want to delete team “{name}”? Players will become ungrouped.',
'npcs.open': 'NPCs',
'npcs.editorTitle': 'NPCs',
'npcs.add': 'Add',
@@ -1011,13 +1093,24 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.avatarRequired': 'Choose an avatar.',
'npcs.chooseAvatar': 'Choose avatar',
'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)',
'npcs.disposition': 'TYPE',
'npcs.disposition.hostile': 'Hostile',
'npcs.disposition.neutral': 'Neutral',
'npcs.disposition.friendly': 'Friendly',
'npcs.makeHostile': 'Make Hostile',
'npcs.makeNeutral': 'Make Neutral',
'npcs.makeFriendly': 'Make Friendly',
'npcs.inactive': 'Inactive',
'npcs.makeActive': 'Make Active',
'npcs.ringColor': 'TOKEN RING COLOR',
'npcs.description': 'DESCRIPTION',
'npcs.descriptionPlaceholder': 'Character description…',
'npcs.descriptionEmpty': 'No description',
'npcs.relations': 'Relations',
'npcs.untitled': 'Untitled',
'npcs.deleteTitle': 'Delete NPC',
'npcs.deleteConfirm': 'Are you sure you want to delete NPC “{name}”? All of their relations will be removed.',
'npcs.deleteConfirm':
'Are you sure you want to delete NPC “{name}”? All of their relations will be removed.',
'npcs.relationCreateTitle': 'Relation name',
'npcs.relationEditTitle': 'Relation name',
'npcs.relationLabel': 'NAME',
@@ -1148,7 +1241,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.passed': 'Visited',
'control.noActiveScene': 'No active scene.',
'control.screenPreview': 'Screen preview',
'control.stopPresentation': 'Stop presentation',
'control.npcTokenScale': 'Play token size',
'control.stopPresentation': 'Turn off',
'control.showPlayers': 'Show players',
'control.hidePlayers': 'Hide players',
'control.videoBrushHint':
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
'control.branches': 'Branch options',
+12 -15
View File
@@ -50,11 +50,7 @@ type Actions = {
importCampaignAudio: () => Promise<void>;
importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
upsertMaterial: (input: {
materialId?: MaterialId;
name: string;
filePath?: string;
}) => Promise<void>;
upsertMaterial: (input: { materialId?: MaterialId; name: string; filePath?: string }) => Promise<void>;
deleteMaterial: (materialId: MaterialId) => Promise<void>;
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
@@ -104,7 +100,10 @@ type Actions = {
mode: 'folder' | 'archive',
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
importFoundryProject: (sourcePath: string) => Promise<void>;
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
peekImportZip: (
labels: StorylineLabels,
targetHasMainStart: boolean,
) => Promise<
| { canceled: true }
| {
canceled: false;
@@ -355,6 +354,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
@@ -509,11 +509,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
await refreshProjects();
};
const upsertMaterial = async (input: {
materialId?: MaterialId;
name: string;
filePath?: string;
}) => {
const upsertMaterial = async (input: { materialId?: MaterialId; name: string; filePath?: string }) => {
const res = await api.invoke(ipcChannels.project.upsertMaterial, input);
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
@@ -531,10 +527,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
await refreshProjects();
};
const setMaterialRotation = async (
materialId: MaterialId,
rotationDeg: 0 | 90 | 180 | 270,
) => {
const setMaterialRotation = async (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => {
const res = await api.invoke(ipcChannels.project.setMaterialRotation, {
materialId,
rotationDeg,
@@ -570,6 +563,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean;
traps?: import('../../../shared/types').SceneTrap[];
tokens?: import('../../../shared/types').SceneToken[];
npcTokens?: import('../../../shared/types').SceneNpcToken[];
grid?: import('../../../shared/types').SceneGrid;
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: { x: number; y: number };
@@ -598,6 +594,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.traps !== undefined ? { traps: patch.traps } : null),
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null),
...(patch.grid !== undefined ? { grid: patch.grid } : null),
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),