feat(grid): именованные шаблоны сетки кампании
Несколько шаблонов в проекте с ручным сохранением и применением (включает сетку); без автоподстановки. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user