d1f180ee0a
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>
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|