Files
Ivan Fontosh d1f180ee0a Initial TTRPG character meta-builder with visual sheet editor.
Offline Electron app seeds 10 system presets, supports create-from-scratch designer with palette/drag/resize/bricks, and exports .ttrpg-system.json for the player.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 14:49:31 +08:00

389 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type { SheetWidget, SystemDefinition, WidgetType } from '../shared/types.ts';
import { WidgetPreview } from './WidgetPreview.tsx';
const PALETTE: Array<{ type: WidgetType; label: string; defaultBind?: string; w: number; h: number }> = [
{ type: 'section', label: 'Section', w: 220, h: 40 },
{ type: 'number', label: 'Number', defaultBind: 'attr.str', w: 110, h: 72 },
{ type: 'modBadge', label: 'Mod badge', defaultBind: 'derived.strMod', w: 110, h: 72 },
{ type: 'dots', label: 'Dots', defaultBind: 'attr.str', w: 160, h: 72 },
{ type: 'track', label: 'Track / HP', defaultBind: 'resource.hp', w: 220, h: 80 },
{ type: 'skillList', label: 'Skill list', w: 280, h: 240 },
{ type: 'notes', label: 'Notes', defaultBind: 'meta.notes', w: 280, h: 140 },
{ type: 'locationBar', label: 'Armor zones', w: 360, h: 90 },
{ type: 'text', label: 'Text', defaultBind: 'meta.title', w: 200, h: 60 },
];
type Props = {
system: SystemDefinition;
onChange: (next: SystemDefinition) => void;
locale: 'ru' | 'en';
};
function snap(n: number, grid = 8): number {
return Math.round(n / grid) * grid;
}
export function DesignerWorkspace({ system, onChange, locale }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const dragRef = useRef<{
id: string;
mode: 'move' | 'resize';
startX: number;
startY: number;
orig: { x: number; y: number; w: number; h: number };
} | null>(null);
const page = system.sheetLayout.pages[pageIndex] ?? system.sheetLayout.pages[0];
const selected = page?.widgets.find((w) => w.id === selectedId) ?? null;
function updateWidgets(mutator: (widgets: SheetWidget[]) => SheetWidget[]) {
if (!page) return;
const pages = system.sheetLayout.pages.map((p, i) =>
i === pageIndex ? { ...p, widgets: mutator(p.widgets) } : p,
);
onChange({ ...system, sheetLayout: { ...system.sheetLayout, pages } });
}
function addWidget(item: (typeof PALETTE)[number]) {
if (!page) return;
const id = `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const widget: SheetWidget = {
id,
type: item.type,
bind: item.defaultBind,
frame: {
x: snap(24 + (page.widgets.length % 5) * 16),
y: snap(24 + (page.widgets.length % 7) * 16),
w: item.w,
h: item.h,
},
props: {
label: item.label,
title: item.label,
max: item.type === 'dots' ? 5 : 20,
},
};
updateWidgets((ws) => [...ws, widget]);
setSelectedId(id);
}
function onPointerDownWidget(e: ReactPointerEvent, id: string, mode: 'move' | 'resize') {
e.stopPropagation();
e.preventDefault();
const w = page?.widgets.find((x) => x.id === id);
if (!w) return;
setSelectedId(id);
dragRef.current = {
id,
mode,
startX: e.clientX,
startY: e.clientY,
orig: { ...w.frame },
};
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
}
function onPointerMove(e: ReactPointerEvent) {
const d = dragRef.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
updateWidgets((ws) =>
ws.map((w) => {
if (w.id !== d.id) return w;
if (d.mode === 'move') {
return {
...w,
frame: {
...w.frame,
x: snap(Math.max(0, d.orig.x + dx)),
y: snap(Math.max(0, d.orig.y + dy)),
},
};
}
return {
...w,
frame: {
...w.frame,
w: snap(Math.max(48, d.orig.w + dx)),
h: snap(Math.max(36, d.orig.h + dy)),
},
};
}),
);
}
function onPointerUp() {
dragRef.current = null;
}
function patchSelected(patch: Partial<SheetWidget> & { props?: Record<string, unknown> }) {
if (!selected) return;
updateWidgets((ws) =>
ws.map((w) =>
w.id === selected.id
? {
...w,
...patch,
props: { ...w.props, ...patch.props },
}
: w,
),
);
}
function removeSelected() {
if (!selected) return;
updateWidgets((ws) => ws.filter((w) => w.id !== selected.id));
setSelectedId(null);
}
function addBrick(kind: 'attributes' | 'skills' | 'resources' | 'derived') {
const key = `new_${Date.now().toString(36).slice(-4)}`;
const bricks = { ...system.bricks };
if (kind === 'attributes') {
bricks.attributes = [
...(bricks.attributes ?? []),
{ key, label: key.toUpperCase(), scale: 'numeric', min: 1, max: 20, default: 10 },
];
} else if (kind === 'skills') {
bricks.skills = [
...(bricks.skills ?? []),
{ key, label: key, scale: 'numeric', default: 0 },
];
} else if (kind === 'resources') {
bricks.resources = [
...(bricks.resources ?? []),
{ key, label: key, min: 0, default: 0, track: true },
];
} else {
bricks.derived = [
...(bricks.derived ?? []),
{
key,
label: key,
formula: 'floor((attr.str - 10) / 2)',
dependsOn: ['attr.str'],
},
];
}
onChange({ ...system, bricks });
}
function removeBrick(kind: 'attributes' | 'skills' | 'resources' | 'derived', key: string) {
const bricks = { ...system.bricks };
if (kind === 'attributes') bricks.attributes = (bricks.attributes ?? []).filter((a) => a.key !== key);
if (kind === 'skills') bricks.skills = (bricks.skills ?? []).filter((a) => a.key !== key);
if (kind === 'resources') bricks.resources = (bricks.resources ?? []).filter((a) => a.key !== key);
if (kind === 'derived') bricks.derived = (bricks.derived ?? []).filter((a) => a.key !== key);
onChange({ ...system, bricks });
}
const bindOptions: string[] = [];
for (const a of system.bricks.attributes ?? []) bindOptions.push(`attr.${a.key}`);
for (const s of system.bricks.skills ?? []) bindOptions.push(`skill.${s.key}`);
for (const r of system.bricks.resources ?? []) bindOptions.push(`resource.${r.key}`);
for (const d of system.bricks.derived ?? []) bindOptions.push(`derived.${d.key}`);
bindOptions.push('meta.notes', 'meta.title');
return (
<div className="designerGrid" data-testid="designer-workspace">
<div className="panel designerSide">
<div className="sideTitle">{locale === 'ru' ? 'Палитра' : 'Palette'}</div>
<div className="palette">
{PALETTE.map((item) => (
<button
key={item.type + item.label}
type="button"
className="paletteItem"
data-testid={`palette-${item.type}`}
onClick={() => addWidget(item)}
>
{item.label}
</button>
))}
</div>
<div className="sideTitle">{locale === 'ru' ? 'Кирпичики' : 'Bricks'}</div>
<div className="row wrap">
<button type="button" onClick={() => addBrick('attributes')}>+ Attr</button>
<button type="button" onClick={() => addBrick('skills')}>+ Skill</button>
<button type="button" onClick={() => addBrick('resources')}>+ Resource</button>
<button type="button" onClick={() => addBrick('derived')}>+ Derived</button>
</div>
<div className="brickList">
{(system.bricks.attributes ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>attr.{a.key}</code>
<button type="button" onClick={() => removeBrick('attributes', a.key)}>×</button>
</div>
))}
{(system.bricks.skills ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>skill.{a.key}</code>
<button type="button" onClick={() => removeBrick('skills', a.key)}>×</button>
</div>
))}
{(system.bricks.resources ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>resource.{a.key}</code>
<button type="button" onClick={() => removeBrick('resources', a.key)}>×</button>
</div>
))}
{(system.bricks.derived ?? []).map((a) => (
<div key={a.key} className="brickRow">
<code>derived.{a.key}</code>
<button type="button" onClick={() => removeBrick('derived', a.key)}>×</button>
</div>
))}
</div>
</div>
<div
className="designerCanvasHost"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={() => setSelectedId(null)}
>
<div className="row" style={{ marginBottom: 8 }}>
{system.sheetLayout.pages.map((p, i) => (
<button
key={p.id}
type="button"
className={i === pageIndex ? 'primary' : ''}
onClick={(e) => {
e.stopPropagation();
setPageIndex(i);
}}
>
{p.label}
</button>
))}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const id = `page_${Date.now().toString(36)}`;
onChange({
...system,
sheetLayout: {
...system.sheetLayout,
pages: [
...system.sheetLayout.pages,
{ id, label: `Page ${system.sheetLayout.pages.length + 1}`, widgets: [] },
],
},
});
setPageIndex(system.sheetLayout.pages.length);
}}
>
+ Page
</button>
</div>
<div className="sheetCanvas designMode" data-testid="sheet-canvas">
{(page?.widgets ?? []).map((w) => (
<div
key={w.id}
className={`widget${selectedId === w.id ? ' selected' : ''}`}
data-testid={`widget-${w.id}`}
style={{ left: w.frame.x, top: w.frame.y, width: w.frame.w, height: w.frame.h }}
onClick={(e) => {
e.stopPropagation();
setSelectedId(w.id);
}}
onPointerDown={(e) => onPointerDownWidget(e, w.id, 'move')}
>
<div className="widgetChrome">{w.type}{w.bind ? ` · ${w.bind}` : ''}</div>
<div className="widgetBody">
<WidgetPreview widget={w} system={system} />
</div>
<div
className="resizeHandle"
onPointerDown={(e) => onPointerDownWidget(e, w.id, 'resize')}
/>
</div>
))}
</div>
</div>
<div className="panel designerSide">
<div className="sideTitle">{locale === 'ru' ? 'Свойства' : 'Properties'}</div>
{!selected ? (
<div className="muted">{locale === 'ru' ? 'Выберите виджет' : 'Select a widget'}</div>
) : (
<div className="propsForm">
<label>
Type
<input value={selected.type} disabled />
</label>
<label>
Bind
<select
data-testid="widget-bind"
value={selected.bind ?? ''}
onChange={(e) => patchSelected({ bind: e.target.value || undefined })}
>
<option value="">—</option>
{bindOptions.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</label>
<label>
Label
<input
value={String(selected.props?.label ?? selected.props?.title ?? '')}
onChange={(e) => patchSelected({ props: { label: e.target.value, title: e.target.value } })}
/>
</label>
<label>
Max
<input
type="number"
value={Number(selected.props?.max ?? 0)}
onChange={(e) => patchSelected({ props: { max: Number(e.target.value) } })}
/>
</label>
<button type="button" data-testid="remove-widget" onClick={removeSelected}>
{locale === 'ru' ? 'Удалить виджет' : 'Remove widget'}
</button>
</div>
)}
<div className="sideTitle" style={{ marginTop: 16 }}>
System
</div>
<label className="propsForm">
ID
<input
data-testid="system-id"
value={system.id}
onChange={(e) => onChange({ ...system, id: e.target.value.trim() || system.id })}
/>
</label>
<label className="propsForm">
Edition
<input
value={system.edition ?? ''}
onChange={(e) => onChange({ ...system, edition: e.target.value })}
/>
</label>
<label className="propsForm">
Description
<textarea
rows={4}
value={system.description ?? ''}
onChange={(e) => onChange({ ...system, description: e.target.value })}
/>
</label>
</div>
</div>
);
}