feat(grid): именованные шаблоны сетки кампании

Несколько шаблонов в проекте с ручным сохранением и применением (включает сетку); без автоподстановки.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-03 12:04:47 +08:00
parent 278eb78637
commit a4d7ab77e7
14 changed files with 681 additions and 6 deletions
+260
View File
@@ -3,10 +3,12 @@ import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type {
GridTemplateId,
NpcDisposition,
NpcId,
ProjectNpc,
SceneGrid,
SceneGridTemplate,
SceneNpcToken,
SceneToken,
SceneTrap,
@@ -32,6 +34,7 @@ import {
import {
clampSceneGridSizeN,
DEFAULT_SCENE_GRID,
MAX_SCENE_GRID_TEMPLATES,
SCENE_GRID_SIZE_MAX,
SCENE_GRID_SIZE_MIN,
sceneGridTokenFitFactor,
@@ -71,6 +74,51 @@ function dispositionMakeKey(d: NpcDisposition): string {
if (d === 'friendly') return 'npcs.makeFriendly';
return 'npcs.makeNeutral';
}
function GridTemplateApplyIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M5 12.5 10 17.5 19 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function GridTemplateRenameIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M4 20h4.2L19.5 8.7a1.8 1.8 0 0 0 0-2.5L17.8 4.5a1.8 1.8 0 0 0-2.5 0L4 15.8V20Z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M13.5 6.5 17.5 10.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function GridTemplateDeleteIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M5 7h14M10 7V5h4v2M8 7l.8 12h6.4L16 7"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
@@ -262,6 +310,11 @@ export function SceneEditorApp() {
const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
const [localNpcTokens, setLocalNpcTokens] = useState<SceneNpcToken[]>([]);
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
const [templateNameDraft, setTemplateNameDraft] = useState('');
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false);
const [renamingTemplateId, setRenamingTemplateId] = useState<GridTemplateId | null>(null);
const [renameDraft, setRenameDraft] = useState('');
const [pendingDeleteTemplate, setPendingDeleteTemplate] = useState<SceneGridTemplate | null>(null);
const trapsRef = useRef<SceneTrap[]>([]);
const tokensRef = useRef<SceneToken[]>([]);
const npcTokensRef = useRef<SceneNpcToken[]>([]);
@@ -365,6 +418,55 @@ export function SceneEditorApp() {
[api, sceneId],
);
const gridTemplates = project?.gridTemplates ?? [];
const defaultTemplateName = useMemo(() => {
const sizeLabel = Math.round(localGrid.sizeN * 100);
return `${sceneGridTypeLabelRu(localGrid.type)} · ${String(sizeLabel)}`;
}, [localGrid.sizeN, localGrid.type]);
const saveGridTemplate = async () => {
if (!sceneId) return;
const name = (templateNameDraft.trim() || defaultTemplateName).trim();
if (!name) return;
if (gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES) return;
await api.invoke(ipcChannels.project.addGridTemplate, {
name,
type: localGrid.type,
sizeN: localGrid.sizeN,
color: localGrid.color,
});
setSaveTemplateOpen(false);
setTemplateNameDraft('');
};
const applyGridTemplate = async (templateId: GridTemplateId) => {
if (!sceneId) return;
if (saveGridTimerRef.current) window.clearTimeout(saveGridTimerRef.current);
await api.invoke(ipcChannels.project.applyGridTemplate, { sceneId, templateId });
};
const commitRenameTemplate = async () => {
if (!renamingTemplateId) return;
const name = renameDraft.trim();
if (!name) {
setRenamingTemplateId(null);
return;
}
await api.invoke(ipcChannels.project.renameGridTemplate, {
templateId: renamingTemplateId,
name,
});
setRenamingTemplateId(null);
};
const confirmDeleteTemplate = async () => {
if (!pendingDeleteTemplate) return;
const id = pendingDeleteTemplate.id;
setPendingDeleteTemplate(null);
await api.invoke(ipcChannels.project.deleteGridTemplate, { templateId: id });
};
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return;
@@ -607,6 +709,123 @@ export function SceneEditorApp() {
}
/>
</label>
<div className={styles.templatesBlock}>
<div className={styles.templatesTitle}>{t('sceneGrid.templates')}</div>
{saveTemplateOpen ? (
<div className={styles.templateSaveRow}>
<Input
value={templateNameDraft}
onChange={setTemplateNameDraft}
placeholder={defaultTemplateName}
data-testid="grid-template-name"
/>
<div className={styles.templateSaveActions}>
<Button
variant="primary"
disabled={gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES}
onClick={() => void saveGridTemplate()}
data-testid="grid-template-save-confirm"
>
{t('common.save')}
</Button>
<Button
variant="ghost"
onClick={() => {
setSaveTemplateOpen(false);
setTemplateNameDraft('');
}}
>
{t('common.cancel')}
</Button>
</div>
</div>
) : (
<Button
variant="ghost"
disabled={!sceneId || gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES}
onClick={() => {
setTemplateNameDraft(defaultTemplateName);
setSaveTemplateOpen(true);
}}
data-testid="grid-template-save"
title={
gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES
? t('sceneGrid.templatesFull', { max: MAX_SCENE_GRID_TEMPLATES })
: undefined
}
>
{t('sceneGrid.saveTemplate')}
</Button>
)}
{gridTemplates.length === 0 ? (
<div className={styles.templatesListSection}>
<div className={styles.templatesEmpty}>{t('sceneGrid.emptyTemplates')}</div>
</div>
) : (
<div className={styles.templatesListSection}>
<div className={styles.templatesList}>
{gridTemplates.map((tpl) => (
<div key={tpl.id} className={styles.templateRow} data-testid={`grid-template-${tpl.id}`}>
<span
className={styles.templateSwatch}
style={{ background: tpl.color }}
aria-hidden
/>
<div className={styles.templateMeta}>
{renamingTemplateId === tpl.id ? (
<Input
value={renameDraft}
onChange={setRenameDraft}
onBlur={() => void commitRenameTemplate()}
data-testid="grid-template-rename-input"
/>
) : (
<div className={styles.templateName}>{tpl.name}</div>
)}
<div className={styles.templateHint}>
{sceneGridTypeLabelRu(tpl.type)} · {Math.round(tpl.sizeN * 100)}
</div>
</div>
<div className={styles.templateActions}>
<Button
variant="ghost"
iconOnly
title={t('sceneGrid.applyTemplate')}
ariaLabel={t('sceneGrid.applyTemplate')}
onClick={() => void applyGridTemplate(tpl.id)}
data-testid={`grid-template-apply-${tpl.id}`}
>
<GridTemplateApplyIcon />
</Button>
<Button
variant="ghost"
iconOnly
title={t('sceneGrid.renameTemplate')}
ariaLabel={t('sceneGrid.renameTemplate')}
onClick={() => {
setRenamingTemplateId(tpl.id);
setRenameDraft(tpl.name);
}}
>
<GridTemplateRenameIcon />
</Button>
<Button
variant="ghost"
iconOnly
title={t('sceneGrid.deleteTemplate')}
ariaLabel={t('sceneGrid.deleteTemplate')}
onClick={() => setPendingDeleteTemplate(tpl)}
>
<GridTemplateDeleteIcon />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
) : null}
</div>
@@ -1264,6 +1483,47 @@ export function SceneEditorApp() {
document.body,
)
: null}
{pendingDeleteTemplate
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteTemplate(null)}
/>
<div
role="dialog"
aria-modal="true"
data-testid="grid-template-delete-confirm"
className={editorStyles.modalDialog}
>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('sceneGrid.deleteTemplateTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalClose}
onClick={() => setPendingDeleteTemplate(null)}
>
×
</button>
</div>
<div className={editorStyles.muted}>
{t('sceneGrid.deleteTemplateConfirm', { name: pendingDeleteTemplate.name })}
</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteTemplate(null)}>{t('common.cancel')}</Button>
<Button variant="primary" onClick={() => void confirmDeleteTemplate()}>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div>
);
}