Extending: Creating Custom Blocks
Relevant source files
The following files were used as context for generating this wiki page:
The Character Sheet Maker is designed with a modular, block-based architecture. Each element on the character sheet canvas—from a title to an image or a text field—is a "Block." This system is extensible, allowing developers to create and integrate new, custom block types with unique functionality and appearance.
This guide outlines the core concepts and steps required to add a new custom block type to the application. It covers the data structure, state management, UI integration, and rendering logic, providing a complete roadmap for extending the editor's capabilities.
The Block Architecture
The entire system revolves around the Block data structure and a central renderer component. All blocks are stored in a global state manager and rendered onto a grid layout canvas.
This diagram shows the high-level interaction between the main components involved in the block system.
The Block Data Structure
Every block on the canvas is an object conforming to the Block interface. This interface defines the block's identity, position, type, content, and configuration.
Sources: src/store/useSheetStore.ts:18-33
This diagram illustrates the structure of the Block interface.
Sources: src/store/useSheetStore.ts:18-33
The properties of the Block interface are detailed below:
| Property | Type | Description |
|---|---|---|
i | string | A unique identifier for the block, required by the react-grid-layout library. |
x | number | The block's horizontal position on the grid (in grid units). |
y | number | The block's vertical position on the grid (in grid units). |
w | number | The block's width on the grid (in grid units). |
h | number | The block's height on the grid (in grid units). |
type | "text" | "image" | "header" | "form-box" | "shape" | "divider" | "list" | "container" | A string literal that determines which rendering logic to use in BlockRenderer.tsx. This is the key to creating a new block. |
data | any | An object containing the actual content of the block, such as { text: "..." } or { src: "..." }. |
config | object | An object for storing configuration options, like level for a header or shapeType for a shape. |
Sources: src/store/useSheetStore.ts:18-33
State Management with Zustand
The application uses Zustand for global state management. The useSheetStore hook provides access to the array of blocks and functions to manipulate them.
Key functions for block management:
addBlock(type, config): Adds a new block of a specifiedtypeto theblocksarray.removeBlock(id): Removes a block from the array by itsid.updateBlockData(id, data): Updates thedataobject for a specific block.updateLayout(layout): Synchronizes the position and dimensions (x,y,w,h) of blocks fromreact-grid-layoutback to the store.
Sources: src/store/useSheetStore.ts:36-121
The following sequence diagram shows the process of adding a new block to the canvas.
How to Create a Custom Block
Creating a new block type involves modifications across several key files. The following steps provide a comprehensive guide.
Step 1: Define the New Block Type
First, you must add your new block's type name to the Block['type'] union in the state management file. This registers the new type within the system.
For example, to add a stat-counter block:
// src/store/useSheetStore.ts
export interface Block {
// ...
type: "text" | "image" | "header" | "form-box" | "shape" | "divider" | "list" | "container" | "stat-counter"; // Add new type here
// ...
}
Sources: src/store/useSheetStore.ts:24
Step 2: Add a Tool to the Sidebar
To allow users to add your new block, you must add a button to the ToolsSidebar component. This involves using the ToolBtn helper component and calling the addBlock function from useSheetStore with your new block's type.
// src/components/layout/ToolsSidebar.tsx
// ...
import { Calculator } from "lucide-react"; // 1. Import an icon
export const ToolsSidebar = ({ isOpen, toggle }: SidebarProps) => {
const { addBlock, ... } = useSheetStore();
return (
// ...
{/* GROUP: MEDIA & FORMS */}
<div>
<SectionTitle>Content</SectionTitle>
<div className="space-y-1">
{/* ... existing buttons */}
{/* 2. Add the new button */}
<ToolBtn icon={Calculator} label="Stat Counter" onClick={() => addBlock("stat-counter")} />
</div>
</div>
// ...
);
};
Sources: src/components/layout/ToolsSidebar.tsx:117-118
Step 3: Implement the Rendering Logic
The BlockRenderer component is responsible for rendering every block based on its type. It's a large functional component containing a series of if statements. To render your new block, you must add a new conditional block that handles its specific logic and JSX.
This flowchart illustrates the logic within BlockRenderer.
Add a new if block to BlockRenderer.tsx:
// src/components/sheet/BlockRenderer.tsx
export const BlockRenderer = ({ block }: { block: Block }) => {
const { updateBlockData, removeBlock, globalStyle } = useSheetStore();
const currentTheme = THEMES[globalStyle.theme];
const DeleteBtn = () => { /* ... */ };
// ... other block types
// --- NEW BLOCK TYPE: STAT COUNTER ---
if (block.type === "stat-counter") {
// Access data, with defaults
const value = block.data?.value || 0;
const label = block.data?.label || "STAT";
// Define update handlers
const increment = () => updateBlockData(block.i, { value: value + 1 });
const decrement = () => updateBlockData(block.i, { value: value - 1 });
return (
<div className="w-full h-full group relative flex flex-col items-center justify-center">
<DeleteBtn />
{/* Your JSX here */}
<div className="text-3xl font-bold">{value}</div>
<input
className="w-full bg-transparent text-center nodrag"
value={label}
onChange={(e) => updateBlockData(block.i, { label: e.target.value })}
/>
{/* Example buttons */}
<div className="flex gap-2">
<button onClick={decrement}>-</button>
<button onClick={increment}>+</button>
</div>
</div>
);
}
return <div className="p-2 text-red-500 text-xs">Unknown Block Type</div>;
};
Sources: src/components/sheet/BlockRenderer.tsx:10-259
Step 4: Define Theming and Styles (Optional)
To make your custom block adapt to different themes, you need to add its style definitions to the ThemeConfig interface and implement them for each theme in src/lib/themes.ts.
-
Update
ThemeConfiginsrc/lib/themes.ts:interface ThemeConfig { // ... blocks: { // ... statCounter: { // Add your new block's style properties value: string; label: string; }; }; }Sources: src/lib/themes.ts:5-29
-
Add styles to each theme object:
// src/lib/themes.ts export const THEMES: Record<ThemeKey, ThemeConfig> = { simple: { // ... blocks: { // ... statCounter: { value: "text-gray-900", label: "text-gray-500 uppercase text-xs" } } }, medieval: { // ... blocks: { // ... statCounter: { value: "text-amber-950", label: "text-amber-800 tracking-widest" } } }, // ... other themes };Sources: src/lib/themes.ts:31-118
-
Apply theme classes in
BlockRenderer.tsx:
Use thecurrentThemeobject to apply the styles to your JSX.// In your new block's render logic if (block.type === "stat-counter") { // ... return ( // ... <div className={cn("text-3xl font-bold", currentTheme.blocks.statCounter.value)}>{value}</div> <input className={cn("w-full bg-transparent text-center nodrag", currentTheme.blocks.statCounter.label)} // ... /> // ... ); }Sources: src/components/sheet/BlockRenderer.tsx:13, src/components/sheet/BlockRenderer.tsx:39-43
Conclusion
The block-based architecture of the Character Sheet Maker is highly extensible. By following the steps of defining a block type, adding a UI trigger in the sidebar, implementing the rendering logic in BlockRenderer, and optionally defining theme-specific styles, developers can seamlessly integrate new and complex components into the application. This modularity is key to supporting a wide variety of TTRPG systems and user-created layouts.