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>
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user