Svelte Integration Guide
This guide covers best practices for integrating Fuderu with Svelte applications, including stores, component patterns, and performance optimization.
Basic Setup
Installation
npm install fuderu
# or
yarn add fuderu
Basic Usage
<script>
import { onMount, onDestroy } from 'svelte';
import { Canvas } from 'fuderu';
let canvas;
let painter = null;
onMount(() => {
if (!canvas) return;
painter = new Canvas({
canvas,
document: {
width: 800,
height: 600
}
});
});
onDestroy(() => {
if (painter) {
painter.destroy();
painter = null;
}
});
</script>
<canvas bind:this={canvas} width="800" height="600" />
Stores
Canvas Store
Create a reusable store for canvas management:
// stores/canvas.js
import { writable } from "svelte/store";
import { Canvas } from "fuderu";
export function createCanvasStore(options = {}) {
const { subscribe, set, update } = writable({
canvas: null,
painter: null,
width: options.width ?? 800,
height: options.height ?? 600,
brushConfig: options.brushConfig ?? {},
});
let painterInstance = null;
return {
subscribe,
setCanvasElement: (canvasElement) => {
update((state) => ({ ...state, canvas: canvasElement }));
initialize(canvasElement);
},
setSize: (width, height) => {
update((state) => ({
...state,
width,
height,
}));
resizeCanvas(width, height);
},
updateBrushConfig: (newConfig) => {
update((state) => ({
...state,
brushConfig: { ...state.brushConfig, ...newConfig },
}));
applyBrushConfig(newConfig);
},
getPainter: () => painterInstance,
destroy: () => {
if (painterInstance) {
painterInstance.destroy();
painterInstance = null;
}
update((state) => ({ ...state, painter: null }));
},
};
function initialize(canvasElement) {
if (!canvasElement) return;
// Clean up existing instance
if (painterInstance) {
painterInstance.destroy();
}
// Create new instance
const { width, height, brushConfig } = getStoreValue();
painterInstance = new Canvas({
canvas: canvasElement,
document: { width, height },
brush: brushConfig,
});
update((state) => ({ ...state, painter: painterInstance }));
}
function resizeCanvas(width, height) {
if (painterInstance) {
painterInstance.setDocumentSize(width, height);
}
}
function applyBrushConfig(newConfig) {
if (painterInstance) {
Object.assign(painterInstance.brush.config, newConfig);
}
}
// Helper to get current store value
function getStoreValue() {
let value;
const unsub = subscribe((val) => {
value = val;
})();
unsub();
return value;
}
}
// Usage in components:
// import { canvasStore } from './stores/canvas';
// canvasStore.setCanvasElement(canvas);
Brush Controls Store
// stores/brushControls.js
import { writable } from "svelte/store";
export function createBrushStore(initialValues = {}) {
const { subscribe, set, update } = writable({
size: 20,
color: "#000000",
opacity: 1,
flow: 1,
angle: 0,
roundness: 1,
spacing: 0.12,
eraser: false,
...initialValues,
});
return {
subscribe,
setSize: (size) => update((s) => ({ ...s, size })),
setColor: (color) => update((s) => ({ ...s, color })),
setOpacity: (opacity) => update((s) => ({ ...s, opacity })),
setFlow: (flow) => update((s) => ({ ...s, flow })),
setAngle: (angle) => update((s) => ({ ...s, angle })),
setRoundness: (roundness) => update((s) => ({ ...s, roundness })),
setSpacing: (spacing) => update((s) => ({ ...s, spacing })),
setEraser: (isEraser) => update((s) => ({ ...s, eraser: isEraser })),
reset: () =>
set({
size: 20,
color: "#000000",
opacity: 1,
flow: 1,
angle: 0,
roundness: 1,
spacing: 0.12,
eraser: false,
}),
};
}
Component Patterns
DrawingCanvas Component
<!-- DrawingCanvas.svelte -->
<script>
import { onMount, onDestroy } from 'svelte';
import { Canvas } from 'fuderu';
import { brushStore } from './stores/brushStore';
export let width = 800;
let height = 600;
let canvas;
let painter = null;
// Subscribe to brush store
let brushUnsubscribe;
onMount(() => {
brushUnsubscribe = brushStore.subscribe((brushConfig) => {
if (painter) {
Object.assign(painter.brush.config, brushConfig);
}
});
if (!canvas) return;
painter = new Canvas({
canvas,
document: { width, height }
});
});
onDestroy(() => {
if (brushUnsubscribe) brushUnsubscribe();
if (painter) {
painter.destroy();
painter = null;
}
});
// Handle resize
function handleResize() {
if (painter) {
// Update dimensions from canvas size
const rect = canvas.getBoundingClientRect();
height = rect.height;
// Width is typically fixed or controlled externally
painter.setDocumentSize(rect.width, rect.height);
}
}
</script>
<canvas
bind:this={canvas}
width={width}
height={height}
on:resize={handleResize}
/>
Brush Controls Component
<!-- BrushControls.svelte -->
<script>
import { brushStore } from './stores/brushStore';
// Create writable aliases for two-way binding
export let size = 20;
export let color = '#000000';
export let opacity = 1;
export let isEraser = false;
// Synchronize with store
$: if ($brushStore) {
size = $brushStore.size;
color = $brushStore.color;
opacity = $brushStore.opacity;
isEraser = $brushStore.eraser;
}
// Update store when local values change
function updateBrush() {
brushStore.update($brushStore => ({
...$brushStore,
size,
color,
opacity,
eraser: isEraser
}));
}
</script>
<div class="brush-controls">
<div class="control-group">
<label>Color:</label>
<input type="color" bind:value={color} on:input={updateBrush} />
</div>
<div class="control-group">
<label>Size:</label>
<input type="range" min="1" max="100" bind:value={size} on:input={updateBrush} />
<span>{size}px</span>
</div>
<div class="control-group">
<label>Opacity:</label>
<input type="range" min="0" max="1" step="0.01" bind:value={opacity} on:input={updateBrush} />
<span>{(opacity * 100).toFixed(0)}%</span>
</div>
<div class="control-group">
<label>
<input type="checkbox" bind:checked={isEraser} on:change={updateBrush} />
Eraser
</label>
</div>
</div>
Layer Manager Component
<!-- LayerManager.svelte -->
<script>
import { writable } from 'svelte/store';
// Layer store
const layerStore = writable([]);
let selectedLayerId = null;
// Expose layer management functions
function addLayer(name = 'New Layer') {
layerStore.update(layers => [
...layers,
{
id: crypto.randomUUID(),
name,
visible: true,
opacity: 1,
blendMode: 'source-over'
}
]);
}
function removeLayer(id) {
layerStore.update(layers => layers.filter(layer => layer.id !== id));
}
function selectLayer(id) {
selectedLayerId = id;
}
function updateLayer(id, updates) {
layerStore.update(layers =>
layers.map(layer =>
layer.id === id ? { ...layer, ...updates } : layer
)
);
}
// Initialize with a default layer
let unsubscribe;
onMount(() => {
if ($layerStore.length === 0) {
addLayer('Background');
}
unsubscribe = layerStore.subscribe(($layers) => {
if ($layers.length === 0) {
addLayer('Background');
}
});
});
onDestroy(() => {
if (unsubscribe) unsubscribe();
});
</script>
<div class="layer-manager">
<h3>Layers</h3>
<button on:click={addLayer}>+ Add Layer</button>
<div class="layer-list">
{#each $layerStore as layer (layer.id)}
<div
class="layer-item {selectedLayerId === layer.id ? 'active' : ''}"
on:click={() => selectLayer(layer.id)}
>
<div class="layer-controls">
<input
type="checkbox"
bind:checked={layer.visible}
on:change={() => updateLayer(layer.id, { visible: !layer.visible })}
/>
<input
type="text"
bind:value={layer.name}
on:input={() => updateLayer(layer.id, { name: layer.name })}
placeholder="Layer name"
/>
</div>
<div class="layer-properties">
<div>
<label>Opacity:</label>
<input
type="range"
min="0"
max="1"
step="0.01"
bind:value={layer.opacity}
on:input={() => updateLayer(layer.id, { opacity: layer.opacity })}
/>
</div>
<div>
<label>Blend Mode:</label>
<select
bind:value={layer.blendMode}
on:change={() => updateLayer(layer.id, { blendMode: layer.blendMode })}
>
<option value="source-over">Normal</option>
<option value="multiply">Multiply</option>
<option value="screen">Screen</option>
<option value="overlay">Overlay</option>
<option value="darken">Darken</option>
<option value="lighten">Lighten</option>
<option value="color-dodge">Color Dodge</option>
<option value="color-burn">Color Burn</option>
<option value="hard-light">Hard Light</option>
<option value="soft-light">Soft Light</option>
<option value="difference">Difference</option>
<option value="exclusion">Exclusion</option>
<option value="hue">Hue</option>
<option value="saturation">Saturation</option>
<option value="color">Color</option>
<option value="luminosity">Luminosity</option>
</select>
</div>
</div>
<button
on:click={(e) => {
e.stopPropagation();
removeLayer(layer.id);
}}
class="remove-layer"
>
×
</button>
</div>
{/each}
</div>
</div>
<style>
.layer-manager {
width: 250px;
}
.layer-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border: 1px solid #eee;
margin-bottom: 4px;
border-radius: 4px;
}
.layer-item.active {
border-color: #007acc;
background-color: #f0f8ff;
}
.layer-controls {
display: flex;
align-items: center;
gap: 8px;
}
.layer-properties {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8em;
}
.remove-layer {
background: none;
border: none;
font-size: 1.2em;
cursor: pointer;
color: #999;
}
.remove-layer:hover {
color: #f00;
}
</style>
Advanced Patterns
Animation Loop with Svelte's Ticker
<script>
import { onMount, onDestroy } from 'svelte';
import { Canvas } from 'fuderu';
import { tick } from 'svelte';
let canvas;
let painter = null;
let animationFrame = null;
let needsRender = false;
onMount(() => {
if (!canvas) return;
painter = new Canvas({
canvas,
// ... options
});
startRenderLoop();
});
onDestroy(() => {
stopRenderLoop();
if (painter) {
painter.destroy();
}
});
function startRenderLoop() {
function loop() {
if (needsRender && painter) {
painter.render();
needsRender = false;
}
animationFrame = requestAnimationFrame(loop);
}
loop();
}
function stopRenderLoop() {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
}
// Function to request a render
function requestRender() {
needsRender = true;
}
// Alternative: Use Svelte's upcoming run loop (experimental)
// import { __unstable_useUpdate } from 'svelte';
// const update = __unstable_useUpdate();
// function requestRender() { update(); }
</script>
<canvas bind:this={canvas} width="800" height="600" />
Custom Event System for Canvas Interactions
// events/canvasEvents.js
import { writable } from "svelte/store";
export function createCanvasEventStore() {
const { subscribe, set, update } = writable({
lastPoint: null,
isDrawing: false,
pressure: 0,
});
return {
subscribe,
setPoint: (x, y, pressure) => {
update((state) => ({
...state,
lastPoint: { x, y, pressure: pressure ?? 0 },
isTurning: true,
}));
},
endStroke: () => {
update((state) => ({ ...state, isDrawing: false, lastPoint: null }));
},
getState: () => {
let state;
const unsub = subscribe((s) => {
state = s;
})();
unsub();
return state;
},
};
}
// Usage in canvas component:
// import { canvasEventStore } from './events/canvasEvents';
//
// onMount(() => {
// // Set up event listeners that update the store
// canvas.addEventListener('pointerdown', (e) => {
// // ... calculate position
// canvasEventStore.setPoint(x, y, pressure);
// });
// // ... other event listeners
// });
Performance Optimization
Request Animation Frame Wrapper
// utils/raf.js
let frameCallback = null;
let frameRequest = null;
let lastTime = 0;
export function initRenderLoop(callback) {
frameCallback = callback;
function loop(timestamp) {
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
if (frameCallback) {
frameCallback(deltaTime);
}
frameRequest = requestAnimationFrame(loop);
}
frameRequest = requestAnimationFrame(loop);
return () => {
if (frameRequest) {
cancelAnimationFrame(frameRequest);
frameRequest = null;
}
frameCallback = null;
};
}
// Usage:
// import { initRenderLoop } from './utils/raf';
//
// let cleanup;
// onMount(() => {
// cleanup = initRenderLoop((deltaTime) => {
// if (painter && needsRender) {
// painter.render();
// needsRender = false;
// }
// });
// });
//
// onDestroy(() => {
// if (cleanup) cleanup();
// });
Virtualized Canvas for Large Documents
<!-- VirtualCanvas.svelte -->
<script>
import { onMount, onDestroy } from 'svelte';
import { Canvas } from 'fuderu';
export let virtualWidth = 4000;
export let virtualHeight = 3000;
export let viewportWidth = 800;
export let viewportHeight = 600;
let container;
let canvas;
let painter = null;
let offset = { x: 0, y: 0 }; // Top-left of viewport in virtual space
let animationFrame = null;
let needsRender = false;
// Create canvas at viewport size, not virtual size
onMount(() => {
if (!container || !canvas) return;
painter = new Canvas({
canvas,
document: { width: viewportWidth, height: viewportHeight }
});
startRenderLoop();
});
onDestroy(() => {
stopRenderLoop();
if (painter) {
painter.destroy();
}
});
function startRenderLoop() {
function loop() {
if (needsRender && painter) {
// Save current state
const savedTransform = painter.brush.context.getTransform();
// Translate to show correct portion of virtual canvas
painter.brush.context.save();
painter.brush.context.translate(-offset.x, -offset.y);
// Render only the visible portion
painter.render();
// Restore state
painter.brush.context.restore();
painter.brush.context.setTransform(savedTransform);
needsRender = false;
}
animationFrame = requestAnimationFrame(loop);
}
loop();
}
function stopRenderLoop() {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
}
function requestRender() {
needsRender = true;
}
// Pan handling
function handleWheel(event) {
event.preventDefault();
// Convert wheel delta to pan movement
const panSpeed = 0.5; // pixels per delta unit
offset.x = Math.max(0, Math.min(virtualWidth - viewportWidth,
offset.x + event.deltaX * panSpeed));
offset.y = Math.max(0, Math.min(virtualHeight - viewportHeight,
offset.y + event.deltaY * panSpeed));
requestRender();
}
// Click-drag panning
let isPanning = false;
let lastPanPoint = null;
function handleMouseDown(event) {
isPanning = true;
lastPanPoint = { x: event.clientX, y: event.clientY };
}
function handleMouseMove(event) {
if (!isPanning || !lastPanPoint) return;
const dx = event.clientX - lastPanPoint.x;
const dy = event.clientY - lastPanPoint.y;
offset.x = Math.max(0, Math.min(virtualWidth - viewportWidth,
offset.x - dx));
offset.y = Math.max(0, Math.min(virtualHeight - viewportHeight,
offset.y - dy));
lastPanPoint = { x: event.clientX, y: event.clientY };
requestRender();
}
function handleMouseUp() {
isPanning = false;
lastPanPoint = null;
}
function handleMouseLeave() {
isPanning = false;
lastPanPoint = null;
}
</script>
<div
bind:this={container}
class="canvas-container"
on:wheel={handleWheel}
on:mousedown={handleMouseDown}
on:mousemove={handleMouseMove}
on:mouseup={handleMouseUp}
on:mouseleave={handleMouseLeave}
style="position: relative; overflow: hidden;"
>
<canvas
bind:this={canvas}
width={viewportWidth}
height={viewportHeight}
style="position: absolute; top: 0; left: 0;"
/>
{/* Optional: show viewport indicator */}
<div
class="viewport-indicator"
style="
position: absolute;
border: 2px dashed #007acc;
pointer-events: none;
"
/>
</div>
<style>
.canvas-container {
width: 100%;
height: 100%;
touch-action: none; /* Important for panning */
}
.viewport-indicator {
/* Will be positioned by JS to show current viewport */
}
</style>
Handling Svelte Specific Features
Stores and Reactivity
<script>
import { writable } from 'svelte/store';
import { Canvas } from 'fuderu';
// Correct way to store non-primitive values
const brushSettings = writable({
size: 20,
color: '#000000'
});
// When updating, always create a new object
function updateBrushSize(newSize) {
brushSettings.update(settings => ({
...settings,
size: newSize
}));
}
// Incorrect - mutating the store value directly (won't trigger updates)
// function badUpdateSize(newSize) {
// brushSettings.update(settings => {
// // This won't work as expected because we're mutating the existing object
// settings.size = newSize;
// return settings; // Same object reference
// });
// }
</script>
Event Handling and Delegation
<script>
import { onMount, onDestroy } from 'svelte';
import { Canvas } from 'fuderu';
let canvas;
let painter = null;
// Event handler reuse to prevent creating new functions on each render
function handlePointerDown(event) {
// ... implementation
}
function handlePointerMove(event) {
// ... implementation
}
function handlePointerUp(event) {
// ... implementation
}
onMount(() => {
if (!canvas) return;
painter = new Canvas({
canvas,
// ... options
});
// Use captured event handlers
canvas.addEventListener('pointerdown', handlePointerDown);
canvas.addEventListener('pointermove', handlePointerMove);
canvas.addEventListener('pointerup', handlePointerUp);
});
onDestroy(() => {
if (!canvas) return;
// Remove the same function references
canvas.removeEventListener('pointerdown', handlePointerDown);
canvas.removeEventListener('pointermove', handlePointerMove);
canvas.removeEventListener('pointerup', handlePointerUp);
if (painter) {
painter.destroy();
}
});
</script>
<canvas bind:this={canvas} width="800" height="600" />
Lifecycle Awareness with Stores
// stores/lifecycleStore.js
import { writable } from "svelte/store";
export function createLifecycleStore() {
const { subscribe, set } = writable({
isMounted: false,
isDestroyed: false,
});
return {
subscribe,
mount: () => set({ isMounted: true, isDestroyed: false }),
destroy: () => set({ isMounted: false, isDestroyed: true }),
reset: () => set({ isMounted: false, isDestroyed: false }),
};
}
// Usage in component:
// import { lifecycleStore } from './stores/lifecycleStore';
//
// $: if ($lifecycleStore.isMounted && !painter) {
// // Initialize
// }
//
// $: if (!$lifecycleStore.isMounted && painter) {
// // Cleanup
// }