feat(control): трекер инициативы в отдельном окне
Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1859,6 +1859,22 @@ export function ControlApp() {
|
||||
⏱
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
title={t('control.initiativeTool')}
|
||||
ariaLabel={t('control.initiativeTool')}
|
||||
data-testid="control-initiative-tool"
|
||||
onClick={() => {
|
||||
void api.invoke(ipcChannels.windows.openInitiative, {}).catch((err) => {
|
||||
console.error('[control] openInitiative failed', err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className={styles.iconGlyph} aria-hidden>
|
||||
⚔
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
|
||||
@@ -659,6 +659,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.materialsTool': 'Материалы',
|
||||
'control.playlistsTool': 'Плейлисты',
|
||||
'control.timerTool': 'Таймер',
|
||||
'control.initiativeTool': 'Инициатива',
|
||||
'control.npcsTool': 'НПС',
|
||||
|
||||
'sessionTimer.duration': 'Длительность (MM:SS)',
|
||||
@@ -668,6 +669,37 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'sessionTimer.showToPlayers': 'Показать игрокам',
|
||||
'sessionTimer.positionHint':
|
||||
'Когда показ включён, перетащите таймер на предпросмотре пульта; углы — размер, ручка сверху — поворот.',
|
||||
|
||||
'initiative.next': 'Следующий',
|
||||
'initiative.queue': 'Очередь',
|
||||
'initiative.queueEmpty': 'Очередь пуста — добавьте вручную или разместите НПС на сцене',
|
||||
'initiative.reserve': 'Запас',
|
||||
'initiative.reserveEmpty': 'Запас пуст',
|
||||
'initiative.addManual': 'Добавить',
|
||||
'initiative.manualPlaceholder': 'Имя вручную',
|
||||
'initiative.toReserve': 'В запас',
|
||||
'initiative.restore': 'Вернуть',
|
||||
'initiative.delete': 'Удалить',
|
||||
'initiative.conditions': 'Состояния',
|
||||
'initiative.conditionsEmpty': 'Все состояния уже добавлены',
|
||||
'initiative.removeCondition': 'Убрать состояние',
|
||||
'initiative.condition.frightened': 'Испуган',
|
||||
'initiative.condition.exhaustion': 'Истощение',
|
||||
'initiative.condition.concentration': 'Концентрация',
|
||||
'initiative.condition.invisible': 'Невидимый',
|
||||
'initiative.condition.incapacitated': 'Недееспособен',
|
||||
'initiative.condition.petrified': 'Окаменевший',
|
||||
'initiative.condition.restrained': 'Опутан',
|
||||
'initiative.condition.blinded': 'Ослеплён',
|
||||
'initiative.condition.poisoned': 'Отравлен',
|
||||
'initiative.condition.charmed': 'Очарован',
|
||||
'initiative.condition.stunned': 'Ошеломлён',
|
||||
'initiative.condition.paralyzed': 'Парализован',
|
||||
'initiative.condition.prone': 'Сбит с ног',
|
||||
'initiative.condition.grappled': 'Схвачен',
|
||||
'initiative.condition.unconscious': 'Без сознания',
|
||||
'initiative.condition.deafened': 'Оглохший',
|
||||
|
||||
'control.descriptionMissing': 'Описание отсутствует',
|
||||
'control.effects': 'ЭФФЕКТЫ',
|
||||
'control.tools': 'Очистка',
|
||||
@@ -1359,6 +1391,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.materialsTool': 'Materials',
|
||||
'control.playlistsTool': 'Playlists',
|
||||
'control.timerTool': 'Timer',
|
||||
'control.initiativeTool': 'Initiative',
|
||||
'control.npcsTool': 'NPCs',
|
||||
|
||||
'sessionTimer.duration': 'Duration (MM:SS)',
|
||||
@@ -1368,6 +1401,37 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'sessionTimer.showToPlayers': 'Show to players',
|
||||
'sessionTimer.positionHint':
|
||||
'When shown, drag the timer on the control preview; corners resize, the top handle rotates.',
|
||||
|
||||
'initiative.next': 'Next',
|
||||
'initiative.queue': 'Queue',
|
||||
'initiative.queueEmpty': 'Queue is empty — add manually or place NPCs on the scene',
|
||||
'initiative.reserve': 'Reserve',
|
||||
'initiative.reserveEmpty': 'Reserve is empty',
|
||||
'initiative.addManual': 'Add',
|
||||
'initiative.manualPlaceholder': 'Manual name',
|
||||
'initiative.toReserve': 'To reserve',
|
||||
'initiative.restore': 'Restore',
|
||||
'initiative.delete': 'Delete',
|
||||
'initiative.conditions': 'Conditions',
|
||||
'initiative.conditionsEmpty': 'All conditions already added',
|
||||
'initiative.removeCondition': 'Remove condition',
|
||||
'initiative.condition.frightened': 'Frightened',
|
||||
'initiative.condition.exhaustion': 'Exhaustion',
|
||||
'initiative.condition.concentration': 'Concentration',
|
||||
'initiative.condition.invisible': 'Invisible',
|
||||
'initiative.condition.incapacitated': 'Incapacitated',
|
||||
'initiative.condition.petrified': 'Petrified',
|
||||
'initiative.condition.restrained': 'Restrained',
|
||||
'initiative.condition.blinded': 'Blinded',
|
||||
'initiative.condition.poisoned': 'Poisoned',
|
||||
'initiative.condition.charmed': 'Charmed',
|
||||
'initiative.condition.stunned': 'Stunned',
|
||||
'initiative.condition.paralyzed': 'Paralyzed',
|
||||
'initiative.condition.prone': 'Prone',
|
||||
'initiative.condition.grappled': 'Grappled',
|
||||
'initiative.condition.unconscious': 'Unconscious',
|
||||
'initiative.condition.deafened': 'Deafened',
|
||||
|
||||
'control.descriptionMissing': 'No description',
|
||||
'control.effects': 'EFFECTS',
|
||||
'control.tools': 'Cleanup',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>TTRPG</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/initiative/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,222 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.addRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.addRow > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text2);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.queue {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
padding: 8px 4px 12px;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
padding: 12px 4px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 110px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.slot:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.slotActive {
|
||||
border-color: rgba(245, 197, 66, 0.55);
|
||||
background: rgba(245, 197, 66, 0.08);
|
||||
}
|
||||
|
||||
.slotActive .avatar {
|
||||
border-color: #f5c542;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(0, 0, 0, 0.45),
|
||||
0 0 12px rgba(245, 197, 66, 0.45);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #3f3f46;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #18181b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.letter {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: #f8fafc;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.slotName {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slotActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.miniBtn {
|
||||
appearance: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.35));
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.miniBtn:hover {
|
||||
border-color: var(--accent, #a78bfa);
|
||||
}
|
||||
|
||||
.conditionMenu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg-elevated, #111827);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conditionItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.conditionItem:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.conditionEmpty {
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.reserve {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.reserveRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.reserveRow .avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.reserveRow .letter {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.reserveName {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
INITIATIVE_CONDITION_IDS,
|
||||
type InitiativeConditionId,
|
||||
type InitiativeEntry,
|
||||
} from '../../shared/types/initiative';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { InitiativeConditionChips } from '../shared/initiative/InitiativeConditionChips';
|
||||
import { useInitiativeState } from '../shared/initiative/useInitiativeState';
|
||||
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
|
||||
import styles from './InitiativeApp.module.css';
|
||||
|
||||
const DND_MIME = 'application/x-dnd-initiative-entry-id';
|
||||
const CONDITION_MENU_WIDTH = 200;
|
||||
/** Компактная высота меню; фактическая не больше доступного места на экране. */
|
||||
const CONDITION_MENU_MAX_H = 220;
|
||||
const CONDITION_MENU_GAP = 6;
|
||||
const CONDITION_MENU_EDGE = 8;
|
||||
|
||||
function EntryAvatar({ entry }: { entry: InitiativeEntry }) {
|
||||
if (entry.kind === 'manual') {
|
||||
return (
|
||||
<div className={styles.avatar} style={{ background: entry.gradient }} aria-hidden>
|
||||
<span className={styles.letter}>{entry.letter}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.kind === 'npc') {
|
||||
return <NpcAvatar assetId={entry.avatarAssetId} name={entry.name} />;
|
||||
}
|
||||
return <PlayerAvatar playerId={entry.playerId} name={entry.name} />;
|
||||
}
|
||||
|
||||
function NpcAvatar({ assetId, name }: { assetId: string | null; name: string }) {
|
||||
const url = useAssetUrl(assetId as import('../../shared/types').AssetId | null);
|
||||
if (url) {
|
||||
return (
|
||||
<div className={styles.avatar}>
|
||||
<img src={url} alt="" className={styles.avatarImg} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const letter = (name.trim()[0] ?? '?').toUpperCase();
|
||||
return (
|
||||
<div className={styles.avatar} style={{ background: 'linear-gradient(135deg,#334155,#0f172a)' }}>
|
||||
<span className={styles.letter}>{letter}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerAvatar({ playerId, name }: { playerId: string; name: string }) {
|
||||
const url = usePlayerImageUrl(playerId as import('../../shared/types').PlayerId);
|
||||
if (url) {
|
||||
return (
|
||||
<div className={styles.avatar}>
|
||||
<img src={url} alt="" className={styles.avatarImg} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const letter = (name.trim()[0] ?? '?').toUpperCase();
|
||||
return (
|
||||
<div className={styles.avatar} style={{ background: 'linear-gradient(135deg,#1d4ed8,#0f172a)' }}>
|
||||
<span className={styles.letter}>{letter}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueSlot({
|
||||
entry,
|
||||
active,
|
||||
conditions,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
conditionsOpen,
|
||||
onToggleConditions,
|
||||
onAddCondition,
|
||||
onRemoveCondition,
|
||||
}: {
|
||||
entry: InitiativeEntry;
|
||||
active: boolean;
|
||||
conditions: readonly InitiativeConditionId[];
|
||||
onSelect: () => void;
|
||||
onRemove: () => void;
|
||||
onDragStart: (e: React.DragEvent) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
conditionsOpen: boolean;
|
||||
onToggleConditions: () => void;
|
||||
onAddCondition: (id: InitiativeConditionId) => void;
|
||||
onRemoveCondition: (id: InitiativeConditionId) => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const available = INITIATIVE_CONDITION_IDS.filter((id) => !conditions.includes(id));
|
||||
const addBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number; maxHeight: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!conditionsOpen) {
|
||||
setMenuPos(null);
|
||||
return;
|
||||
}
|
||||
const btn = addBtnRef.current;
|
||||
if (!btn) return;
|
||||
const place = () => {
|
||||
const r = btn.getBoundingClientRect();
|
||||
const left = Math.max(
|
||||
CONDITION_MENU_EDGE,
|
||||
Math.min(
|
||||
window.innerWidth - CONDITION_MENU_WIDTH - CONDITION_MENU_EDGE,
|
||||
r.left,
|
||||
),
|
||||
);
|
||||
const spaceBelow = window.innerHeight - r.bottom - CONDITION_MENU_GAP - CONDITION_MENU_EDGE;
|
||||
const spaceAbove = r.top - CONDITION_MENU_GAP - CONDITION_MENU_EDGE;
|
||||
// Предпочитаем под кнопкой; если снизу мало места — над ней.
|
||||
const openBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove;
|
||||
const available = Math.max(80, openBelow ? spaceBelow : spaceAbove);
|
||||
const maxHeight = Math.min(CONDITION_MENU_MAX_H, available);
|
||||
const top = openBelow
|
||||
? r.bottom + CONDITION_MENU_GAP
|
||||
: Math.max(CONDITION_MENU_EDGE, r.top - CONDITION_MENU_GAP - maxHeight);
|
||||
setMenuPos({ top, left, maxHeight });
|
||||
};
|
||||
place();
|
||||
window.addEventListener('resize', place);
|
||||
window.addEventListener('scroll', place, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', place);
|
||||
window.removeEventListener('scroll', place, true);
|
||||
};
|
||||
}, [conditionsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conditionsOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (addBtnRef.current?.contains(target)) return;
|
||||
const menu = document.getElementById(`initiative-condition-menu-${entry.id}`);
|
||||
if (menu?.contains(target)) return;
|
||||
onToggleConditions();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
return () => document.removeEventListener('mousedown', onDown);
|
||||
}, [conditionsOpen, entry.id, onToggleConditions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.slot, active ? styles.slotActive : ''].filter(Boolean).join(' ')}
|
||||
data-testid={`initiative-slot-${entry.id}`}
|
||||
data-active={active ? '1' : '0'}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<EntryAvatar entry={entry} />
|
||||
<div className={styles.slotName} title={entry.name}>
|
||||
{entry.name}
|
||||
</div>
|
||||
<InitiativeConditionChips
|
||||
conditions={conditions}
|
||||
removable
|
||||
onRemove={onRemoveCondition}
|
||||
/>
|
||||
<div className={styles.slotActions}>
|
||||
<button
|
||||
ref={addBtnRef}
|
||||
type="button"
|
||||
className={styles.miniBtn}
|
||||
title={t('initiative.conditions')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleConditions();
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.miniBtn}
|
||||
title={t('initiative.toReserve')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{conditionsOpen && menuPos
|
||||
? createPortal(
|
||||
<div
|
||||
id={`initiative-condition-menu-${entry.id}`}
|
||||
className={styles.conditionMenu}
|
||||
style={{
|
||||
top: menuPos.top,
|
||||
left: menuPos.left,
|
||||
width: CONDITION_MENU_WIDTH,
|
||||
maxHeight: menuPos.maxHeight,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{available.length === 0 ? (
|
||||
<div className={styles.conditionEmpty}>{t('initiative.conditionsEmpty')}</div>
|
||||
) : (
|
||||
available.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={styles.conditionItem}
|
||||
onClick={() => onAddCondition(id)}
|
||||
>
|
||||
{t(`initiative.condition.${id}`)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InitiativeApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [state, initiativeApi] = useInitiativeState();
|
||||
const [manualName, setManualName] = useState('');
|
||||
const [conditionsFor, setConditionsFor] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
void api.invoke(ipcChannels.windows.closeInitiative, {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [api]);
|
||||
|
||||
const orderEntries = useMemo(() => {
|
||||
if (!state) return [];
|
||||
return state.order.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[];
|
||||
}, [state]);
|
||||
|
||||
const reserveEntries = useMemo(() => {
|
||||
if (!state) return [];
|
||||
return state.reserve.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[];
|
||||
}, [state]);
|
||||
|
||||
if (!state) {
|
||||
return <div className={styles.page} data-testid="initiative-app" />;
|
||||
}
|
||||
|
||||
const reorderDrop = (targetId: string, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const fromId = e.dataTransfer.getData(DND_MIME);
|
||||
if (!fromId || fromId === targetId) return;
|
||||
const next = [...state.order];
|
||||
const fromIdx = next.indexOf(fromId);
|
||||
const toIdx = next.indexOf(targetId);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, fromId);
|
||||
void initiativeApi.dispatch({ kind: 'reorder', order: next });
|
||||
};
|
||||
|
||||
const addManual = () => {
|
||||
const name = manualName.trim();
|
||||
if (!name) return;
|
||||
void initiativeApi.dispatch({ kind: 'addManual', name });
|
||||
setManualName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="initiative-app">
|
||||
<div className={styles.toolbar}>
|
||||
<Button variant="primary" onClick={() => void initiativeApi.dispatch({ kind: 'next' })}>
|
||||
{t('initiative.next')}
|
||||
</Button>
|
||||
<div className={styles.addRow}>
|
||||
<Input
|
||||
value={manualName}
|
||||
onChange={setManualName}
|
||||
placeholder={t('initiative.manualPlaceholder')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addManual();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" onClick={addManual}>
|
||||
{t('initiative.addManual')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sectionLabel}>{t('initiative.queue')}</div>
|
||||
<div className={styles.queue} data-testid="initiative-queue">
|
||||
{orderEntries.length === 0 ? (
|
||||
<div className={styles.empty}>{t('initiative.queueEmpty')}</div>
|
||||
) : (
|
||||
orderEntries.map((entry) => (
|
||||
<QueueSlot
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
active={state.activeId === entry.id}
|
||||
conditions={state.conditions[entry.id] ?? []}
|
||||
onSelect={() => void initiativeApi.dispatch({ kind: 'setActive', id: entry.id })}
|
||||
onRemove={() => void initiativeApi.dispatch({ kind: 'moveToReserve', id: entry.id })}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(DND_MIME, entry.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}}
|
||||
onDrop={(e) => reorderDrop(entry.id, e)}
|
||||
conditionsOpen={conditionsFor === entry.id}
|
||||
onToggleConditions={() =>
|
||||
setConditionsFor((cur) => (cur === entry.id ? null : entry.id))
|
||||
}
|
||||
onAddCondition={(conditionId) => {
|
||||
void initiativeApi.dispatch({ kind: 'addCondition', id: entry.id, conditionId });
|
||||
setConditionsFor(null);
|
||||
}}
|
||||
onRemoveCondition={(conditionId) => {
|
||||
void initiativeApi.dispatch({ kind: 'removeCondition', id: entry.id, conditionId });
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.sectionLabel}>{t('initiative.reserve')}</div>
|
||||
<div className={styles.reserve} data-testid="initiative-reserve">
|
||||
{reserveEntries.length === 0 ? (
|
||||
<div className={styles.empty}>{t('initiative.reserveEmpty')}</div>
|
||||
) : (
|
||||
reserveEntries.map((entry) => (
|
||||
<div key={entry.id} className={styles.reserveRow}>
|
||||
<EntryAvatar entry={entry} />
|
||||
<span className={styles.reserveName}>{entry.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void initiativeApi.dispatch({ kind: 'restoreFromReserve', id: entry.id })}
|
||||
>
|
||||
{t('initiative.restore')}
|
||||
</Button>
|
||||
{entry.kind === 'manual' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void initiativeApi.dispatch({ kind: 'deleteEntry', id: entry.id })}
|
||||
>
|
||||
{t('initiative.delete')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { InitiativeApp } from './InitiativeApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Initiative">
|
||||
<EditorI18nProvider>
|
||||
<InitiativeApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { PresentationApp } from './PresentationApp';
|
||||
@@ -14,7 +15,9 @@ if (!rootEl) {
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Презентация">
|
||||
<PresentationApp />
|
||||
<EditorI18nProvider>
|
||||
<PresentationApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.compact {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.75);
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
color: #93c5fd;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.compact .chip {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chipRemove {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chipRemove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { InitiativeConditionId } from '../../../shared/types/initiative';
|
||||
import { useEditorI18n } from '../../editor/i18n/EditorI18nContext';
|
||||
|
||||
import styles from './InitiativeConditionChips.module.css';
|
||||
|
||||
export function InitiativeConditionChips({
|
||||
conditions,
|
||||
removable = false,
|
||||
onRemove,
|
||||
compact = false,
|
||||
}: {
|
||||
conditions: readonly InitiativeConditionId[];
|
||||
removable?: boolean;
|
||||
onRemove?: (id: InitiativeConditionId) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
if (conditions.length === 0) return null;
|
||||
return (
|
||||
<div className={[styles.row, compact ? styles.compact : ''].filter(Boolean).join(' ')}>
|
||||
{conditions.map((id) => (
|
||||
<span key={id} className={styles.chip} data-testid={`initiative-condition-${id}`}>
|
||||
<span className={styles.chipLabel}>{t(`initiative.condition.${id}`)}</span>
|
||||
{removable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipRemove}
|
||||
aria-label={t('initiative.removeCondition')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.wrap {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 100%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { InitiativeConditionId } from '../../../shared/types/initiative';
|
||||
|
||||
import { InitiativeConditionChips } from './InitiativeConditionChips';
|
||||
import styles from './TokenInitiativeDecor.module.css';
|
||||
|
||||
export {
|
||||
initiativeConditionsForNpc,
|
||||
initiativeConditionsForPlayer,
|
||||
initiativeHighlightForNpc,
|
||||
initiativeHighlightForPlayer,
|
||||
} from './initiativeSceneHelpers';
|
||||
|
||||
/** Чипы состояний под токеном (видны и на презентации). */
|
||||
export function TokenInitiativeDecor({
|
||||
conditions,
|
||||
}: {
|
||||
conditions: readonly InitiativeConditionId[];
|
||||
}) {
|
||||
if (conditions.length === 0) return null;
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<InitiativeConditionChips conditions={conditions} compact />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { InitiativeConditionId, InitiativeState } from '../../../shared/types/initiative';
|
||||
import {
|
||||
initiativeNpcEntryId,
|
||||
initiativePlayerEntryId,
|
||||
} from '../../../shared/types/initiative';
|
||||
|
||||
export function initiativeHighlightForNpc(
|
||||
state: InitiativeState | null | undefined,
|
||||
placementId: string,
|
||||
): boolean {
|
||||
if (!state?.enabled || !state.activeId) return false;
|
||||
return state.activeId === initiativeNpcEntryId(placementId);
|
||||
}
|
||||
|
||||
export function initiativeHighlightForPlayer(
|
||||
state: InitiativeState | null | undefined,
|
||||
playerId: string,
|
||||
): boolean {
|
||||
if (!state?.enabled || !state.activeId) return false;
|
||||
return state.activeId === initiativePlayerEntryId(playerId);
|
||||
}
|
||||
|
||||
export function initiativeConditionsForNpc(
|
||||
state: InitiativeState | null | undefined,
|
||||
placementId: string,
|
||||
): InitiativeConditionId[] {
|
||||
if (!state) return [];
|
||||
return state.conditions[initiativeNpcEntryId(placementId)] ?? [];
|
||||
}
|
||||
|
||||
export function initiativeConditionsForPlayer(
|
||||
state: InitiativeState | null | undefined,
|
||||
playerId: string,
|
||||
): InitiativeConditionId[] {
|
||||
if (!state) return [];
|
||||
return state.conditions[initiativePlayerEntryId(playerId)] ?? [];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { InitiativeEvent, InitiativeState } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useInitiativeState(): readonly [
|
||||
InitiativeState | null,
|
||||
{ dispatch: (event: InitiativeEvent) => Promise<InitiativeState> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<InitiativeState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.initiative.getState, {}).then((r) => {
|
||||
setState(r.state);
|
||||
});
|
||||
return api.on(ipcChannels.initiative.stateChanged, ({ state: next }) => {
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return [
|
||||
state,
|
||||
{
|
||||
dispatch: async (event) => {
|
||||
const r = await api.invoke(ipcChannels.initiative.dispatch, { event });
|
||||
setState(r.state);
|
||||
return r.state;
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
@@ -30,6 +30,27 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.initiativeActive {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Кольцо строго по центру круга токена (обёртка = sizePx × sizePx). */
|
||||
.initiativeActive::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 3px solid #f59e0b;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
|
||||
@@ -17,6 +17,12 @@ import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
||||
import {
|
||||
TokenInitiativeDecor,
|
||||
initiativeConditionsForNpc,
|
||||
initiativeHighlightForNpc,
|
||||
} from '../initiative/TokenInitiativeDecor';
|
||||
import { useInitiativeState } from '../initiative/useInitiativeState';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
@@ -48,6 +54,8 @@ function NpcSprite({
|
||||
resizeTitle,
|
||||
onContextMenu,
|
||||
snapNorm,
|
||||
initiativeActive = false,
|
||||
initiativeConditions = [],
|
||||
}: {
|
||||
placement: SceneNpcToken;
|
||||
npc: ProjectNpc;
|
||||
@@ -66,6 +74,8 @@ function NpcSprite({
|
||||
resizeTitle?: string;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
initiativeActive?: boolean;
|
||||
initiativeConditions?: readonly import('../../../shared/types/initiative').InitiativeConditionId[];
|
||||
}) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
@@ -123,8 +133,15 @@ function NpcSprite({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, canDrag ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
className={[
|
||||
styles.token,
|
||||
canDrag ? styles.editable : '',
|
||||
initiativeActive ? styles.initiativeActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
data-testid={`session-npc-token-${placement.id}`}
|
||||
data-initiative-active={initiativeActive ? '1' : '0'}
|
||||
style={{
|
||||
left: viewport.x + pos.nx * viewport.w,
|
||||
top: viewport.y + pos.ny * viewport.h,
|
||||
@@ -189,6 +206,7 @@ function NpcSprite({
|
||||
sizePx={sizePx}
|
||||
inactive={inactive}
|
||||
/>
|
||||
<TokenInitiativeDecor conditions={initiativeConditions} />
|
||||
{canResize ? (
|
||||
<div
|
||||
className={styles.resizeHandle}
|
||||
@@ -258,6 +276,7 @@ export function SceneNpcTokensOverlay({
|
||||
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
|
||||
dragEnabledById?: Record<string, boolean> | null;
|
||||
}) {
|
||||
const [initiative] = useInitiativeState();
|
||||
if (!viewport) return null;
|
||||
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
||||
const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE;
|
||||
@@ -288,6 +307,8 @@ export function SceneNpcTokensOverlay({
|
||||
gridFit={gridFit}
|
||||
disposition={disposition}
|
||||
inactive={inactive}
|
||||
initiativeActive={initiativeHighlightForNpc(initiative, key)}
|
||||
initiativeConditions={initiativeConditionsForNpc(initiative, key)}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(onResize ? { onResize } : {})}
|
||||
{...(resizeTitle ? { resizeTitle } : {})}
|
||||
|
||||
@@ -14,6 +14,12 @@ import { usePlayerImageUrl } from './usePlayerImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
||||
import {
|
||||
TokenInitiativeDecor,
|
||||
initiativeConditionsForPlayer,
|
||||
initiativeHighlightForPlayer,
|
||||
} from '../initiative/TokenInitiativeDecor';
|
||||
import { useInitiativeState } from '../initiative/useInitiativeState';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
@@ -31,6 +37,8 @@ function PlayerSprite({
|
||||
onResize,
|
||||
resizeTitle,
|
||||
snapNorm,
|
||||
initiativeActive = false,
|
||||
initiativeConditions = [],
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
nx: number;
|
||||
@@ -44,6 +52,8 @@ function PlayerSprite({
|
||||
onResize?: (playerId: string, sizeN: number) => void;
|
||||
resizeTitle?: string;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
initiativeActive?: boolean;
|
||||
initiativeConditions?: readonly import('../../../shared/types/initiative').InitiativeConditionId[];
|
||||
}) {
|
||||
const imageUrl = usePlayerImageUrl(player.id);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
@@ -100,8 +110,15 @@ function PlayerSprite({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
className={[
|
||||
styles.token,
|
||||
editable ? styles.editable : '',
|
||||
initiativeActive ? styles.initiativeActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
data-testid={`session-player-token-${player.id}`}
|
||||
data-initiative-active={initiativeActive ? '1' : '0'}
|
||||
style={{
|
||||
left: viewport.x + pos.nx * viewport.w,
|
||||
top: viewport.y + pos.ny * viewport.h,
|
||||
@@ -155,6 +172,7 @@ function PlayerSprite({
|
||||
imageScale={player.imageScale}
|
||||
sizePx={sizePx}
|
||||
/>
|
||||
<TokenInitiativeDecor conditions={initiativeConditions} />
|
||||
{canResize ? (
|
||||
<div
|
||||
className={styles.resizeHandle}
|
||||
@@ -218,6 +236,7 @@ export function ScenePlayerTokensOverlay({
|
||||
resizeTitle?: string;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
const [initiative] = useInitiativeState();
|
||||
if (!viewport || !session?.visible) return null;
|
||||
const byId = new Map(library.map((p) => [String(p.id), p]));
|
||||
const gridFit = sceneGridTokenFitFactor(grid);
|
||||
@@ -238,6 +257,8 @@ export function ScenePlayerTokensOverlay({
|
||||
editable={editable}
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
initiativeActive={initiativeHighlightForPlayer(initiative, playerId)}
|
||||
initiativeConditions={initiativeConditionsForPlayer(initiative, playerId)}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(onResize ? { onResize } : {})}
|
||||
{...(resizeTitle ? { resizeTitle } : {})}
|
||||
|
||||
@@ -14,6 +14,7 @@ const SECONDARY_WINDOW_MAINS = [
|
||||
'materials/main.tsx',
|
||||
'playlists/main.tsx',
|
||||
'timer/main.tsx',
|
||||
'initiative/main.tsx',
|
||||
'sceneEditor/main.tsx',
|
||||
'sceneDescription/main.tsx',
|
||||
'control/main.tsx',
|
||||
@@ -148,6 +149,12 @@ void test('ControlApp: Timer tool opens window', () => {
|
||||
assert.ok(src.includes('SessionTimerLayer'));
|
||||
});
|
||||
|
||||
void test('ControlApp: Initiative tool opens window', () => {
|
||||
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
|
||||
assert.ok(src.includes('data-testid="control-initiative-tool"'));
|
||||
assert.ok(src.includes('windows.openInitiative'));
|
||||
});
|
||||
|
||||
void test('PlayerTokenView: name plate shows full text without ellipsis', () => {
|
||||
const css = fs.readFileSync(
|
||||
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
|
||||
|
||||
Reference in New Issue
Block a user