iwb_canvas_engine 0.0.3
iwb_canvas_engine: ^0.0.3 copied to clipboard
Scene-based canvas engine for Flutter: model, rendering, input, JSON serialization.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:iwb_canvas_engine/iwb_canvas_engine.dart';
void main() {
runApp(const CanvasExampleApp());
}
class CanvasExampleApp extends StatelessWidget {
const CanvasExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'IWB Canvas Engine Example',
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF1565C0),
),
home: const CanvasExampleScreen(),
);
}
}
class CanvasExampleScreen extends StatefulWidget {
const CanvasExampleScreen({super.key});
@override
State<CanvasExampleScreen> createState() => _CanvasExampleScreenState();
}
class _CanvasExampleScreenState extends State<CanvasExampleScreen> {
static const double _cameraMinX = -400;
static const double _cameraMaxX = 400;
late final SceneController _controller;
int _sampleSeed = 0;
int _nodeSeed = 0;
@override
void initState() {
super.initState();
_controller = SceneController(scene: _createScene());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('IWB Canvas Engine Example')),
body: SafeArea(
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Column(
children: [
_buildToolbar(context),
const Divider(height: 1),
Expanded(child: _buildCanvas()),
],
);
},
),
),
);
}
Widget _buildToolbar(BuildContext context) {
final scene = _controller.scene;
final grid = scene.background.grid;
final hasSelection = _controller.selectedNodeIds.isNotEmpty;
final isDrawMode = _controller.mode == CanvasMode.draw;
final theme = Theme.of(context);
final selectedTextNodes = _selectedTextNodes();
final hasTextSelection = selectedTextNodes.isNotEmpty;
final primaryTextNode = hasTextSelection ? selectedTextNodes.first : null;
final textColor = primaryTextNode?.color ?? scene.palette.penColors.first;
final textAlign = primaryTextNode?.align ?? TextAlign.left;
final textFontSize = primaryTextNode?.fontSize ?? 24;
final lineHeightMultiplier = primaryTextNode == null || textFontSize == 0
? 1.0
: (primaryTextNode.lineHeight ?? textFontSize) / textFontSize;
final cameraX = _controller.scene.camera.offset.dx;
return Material(
elevation: 1,
color: theme.colorScheme.surface,
child: Padding(
padding: const EdgeInsets.all(8),
child: Wrap(
spacing: 16,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_ToolbarGroup(
label: 'Mode',
child: SegmentedButton<CanvasMode>(
segments: const [
ButtonSegment(
value: CanvasMode.move,
label: Text('Move'),
icon: Icon(Icons.open_with),
),
ButtonSegment(
value: CanvasMode.draw,
label: Text('Draw'),
icon: Icon(Icons.edit),
),
],
selected: {_controller.mode},
onSelectionChanged: (value) {
if (value.isEmpty) return;
_controller.setMode(value.first);
},
),
),
_ToolbarGroup(
label: 'Tool',
child: SegmentedButton<DrawTool>(
segments: const [
ButtonSegment(
value: DrawTool.pen,
label: Text('Pen'),
icon: Icon(Icons.brush),
),
ButtonSegment(
value: DrawTool.highlighter,
label: Text('Highlighter'),
icon: Icon(Icons.border_color),
),
ButtonSegment(
value: DrawTool.line,
label: Text('Line'),
icon: Icon(Icons.show_chart),
),
ButtonSegment(
value: DrawTool.eraser,
label: Text('Eraser'),
icon: Icon(Icons.auto_fix_normal),
),
],
selected: {_controller.drawTool},
onSelectionChanged: isDrawMode
? (value) {
if (value.isEmpty) return;
_controller.setDrawTool(value.first);
}
: null,
),
),
_ToolbarGroup(
label: 'Actions',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Rotate left',
onPressed: hasSelection
? () => _controller.rotateSelection(clockwise: false)
: null,
icon: const Icon(Icons.rotate_left),
),
IconButton(
tooltip: 'Rotate right',
onPressed: hasSelection
? () => _controller.rotateSelection(clockwise: true)
: null,
icon: const Icon(Icons.rotate_right),
),
IconButton(
tooltip: 'Flip vertical',
onPressed: hasSelection
? () => _controller.flipSelectionVertical()
: null,
icon: const Icon(Icons.flip),
),
IconButton(
tooltip: 'Delete',
onPressed: hasSelection
? () => _controller.deleteSelection()
: null,
icon: const Icon(Icons.delete_outline),
),
IconButton(
tooltip: 'Clear',
onPressed: () => _controller.clearScene(),
icon: const Icon(Icons.clear_all),
),
],
),
),
if (hasTextSelection)
_ToolbarGroup(
label: 'Text',
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 220,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Size: ${textFontSize.toStringAsFixed(0)}',
style: theme.textTheme.labelMedium,
),
Slider(
value: textFontSize.clamp(10, 72).toDouble(),
min: 10,
max: 72,
divisions: 62,
label: textFontSize.toStringAsFixed(0),
onChanged: _setSelectedTextFontSize,
),
],
),
),
const SizedBox(height: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Bold',
onPressed: _toggleSelectedTextBold,
isSelected: primaryTextNode?.isBold ?? false,
icon: const Icon(Icons.format_bold),
),
IconButton(
tooltip: 'Italic',
onPressed: _toggleSelectedTextItalic,
isSelected: primaryTextNode?.isItalic ?? false,
icon: const Icon(Icons.format_italic),
),
IconButton(
tooltip: 'Underline',
onPressed: _toggleSelectedTextUnderline,
isSelected: primaryTextNode?.isUnderline ?? false,
icon: const Icon(Icons.format_underline),
),
const SizedBox(width: 8),
SegmentedButton<TextAlign>(
segments: const [
ButtonSegment(
value: TextAlign.left,
icon: Icon(Icons.format_align_left),
label: Text('Left'),
),
ButtonSegment(
value: TextAlign.center,
icon: Icon(Icons.format_align_center),
label: Text('Center'),
),
ButtonSegment(
value: TextAlign.right,
icon: Icon(Icons.format_align_right),
label: Text('Right'),
),
],
selected: {textAlign},
onSelectionChanged: (value) {
if (value.isEmpty) return;
_setSelectedTextAlign(value.first);
},
),
],
),
const SizedBox(height: 8),
_ColorPalette(
colors: scene.palette.penColors,
selected: textColor,
onSelected: _setSelectedTextColor,
),
const SizedBox(height: 8),
SizedBox(
width: 220,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Line height: ${lineHeightMultiplier.toStringAsFixed(2)}',
style: theme.textTheme.labelMedium,
),
Slider(
value: lineHeightMultiplier
.clamp(0.8, 2.0)
.toDouble(),
min: 0.8,
max: 2.0,
divisions: 12,
label: lineHeightMultiplier.toStringAsFixed(2),
onChanged: _setSelectedTextLineHeight,
),
],
),
),
],
),
),
_ToolbarGroup(
label: 'Samples',
child: FilledButton.icon(
onPressed: _addSampleObjects,
icon: const Icon(Icons.add_box_outlined),
label: const Text('Add objects'),
),
),
_ToolbarGroup(
label: 'Pen color',
child: _ColorPalette(
colors: scene.palette.penColors,
selected: _controller.drawColor,
onSelected: _setDrawColor,
),
),
_ToolbarGroup(
label: 'Background',
child: _ColorPalette(
colors: scene.palette.backgroundColors,
selected: scene.background.color,
onSelected: _setBackgroundColor,
),
),
_ToolbarGroup(
label: 'Grid',
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch(value: grid.isEnabled, onChanged: _setGridEnabled),
Text(grid.isEnabled ? 'On' : 'Off'),
],
),
SegmentedButton<double>(
segments: scene.palette.gridSizes
.map(
(size) => ButtonSegment<double>(
value: size,
label: Text('${size.toInt()}'),
),
)
.toList(growable: false),
selected: {grid.cellSize},
onSelectionChanged: grid.isEnabled
? (value) {
if (value.isEmpty) return;
_setGridSize(value.first);
}
: null,
),
],
),
),
_ToolbarGroup(
label: 'Camera X',
child: SizedBox(
width: 220,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Slider(
value: cameraX.clamp(_cameraMinX, _cameraMaxX).toDouble(),
min: _cameraMinX,
max: _cameraMaxX,
divisions: 80,
label: cameraX.toStringAsFixed(0),
onChanged: _setCameraX,
),
Text(
'Offset: ${cameraX.toStringAsFixed(0)}',
style: theme.textTheme.labelMedium,
),
],
),
),
),
if (_controller.hasPendingLineStart)
_ToolbarGroup(
label: 'Line start',
child: Chip(
avatar: const Icon(Icons.adjust, size: 16),
label: const Text('Tap to set end'),
backgroundColor: theme.colorScheme.secondaryContainer
.withAlpha((0.6 * 255).round()),
),
),
],
),
),
);
}
Widget _buildCanvas() {
return Stack(
children: [
SceneView(controller: _controller, imageResolver: (_) => null),
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _PendingLineMarkerPainter(controller: _controller),
),
),
),
],
);
}
Scene _createScene() {
return Scene(layers: [Layer()]);
}
List<TextNode> _selectedTextNodes() {
final selectedIds = _controller.selectedNodeIds;
if (selectedIds.isEmpty) return const <TextNode>[];
final nodes = <TextNode>[];
for (final layer in _controller.scene.layers) {
for (final node in layer.nodes) {
if (node is TextNode && selectedIds.contains(node.id)) {
nodes.add(node);
}
}
}
return nodes;
}
void _updateSelectedTextNodes(void Function(TextNode node) update) {
final nodes = _selectedTextNodes();
if (nodes.isEmpty) return;
for (final node in nodes) {
update(node);
}
_controller.notifySceneChanged();
}
void _addSampleObjects() {
final scene = _controller.scene;
final layer = _ensureContentLayer(scene);
final baseX = 120 + (_sampleSeed * 30);
final baseY = 120 + (_sampleSeed * 20);
layer.nodes.addAll([
RectNode(
id: _nextSampleId(),
size: const Size(140, 90),
fillColor: const Color(0xFFBBDEFB),
strokeColor: const Color(0xFF1E88E5),
strokeWidth: 2,
)..position = Offset(baseX.toDouble(), baseY.toDouble()),
TextNode(
id: _nextSampleId(),
text: 'Hello canvas',
size: const Size(180, 60),
fontSize: 24,
color: const Color(0xFF263238),
isBold: true,
)..position = Offset(baseX + 220, baseY.toDouble()),
PathNode(
id: _nextSampleId(),
svgPathData: 'M0 0 H40 V30 H0 Z M12 8 H28 V22 H12 Z',
fillRule: PathFillRule.evenOdd,
fillColor: const Color(0xFF81C784),
strokeColor: const Color(0xFF2E7D32),
strokeWidth: 2,
)..position = Offset(baseX + 100, baseY + 160),
ImageNode(
id: _nextSampleId(),
imageId: 'sample-image',
size: const Size(120, 90),
)..position = Offset(baseX + 280, baseY + 160),
LineNode(
id: _nextSampleId(),
start: Offset(baseX.toDouble(), baseY + 220),
end: Offset(baseX + 180, baseY + 260),
thickness: 4,
color: const Color(0xFFE53935),
),
]);
_sampleSeed += 1;
_controller.notifySceneChanged();
}
Layer _ensureContentLayer(Scene scene) {
for (var i = scene.layers.length - 1; i >= 0; i--) {
final layer = scene.layers[i];
if (!layer.isBackground) return layer;
}
final layer = Layer();
scene.layers.add(layer);
return layer;
}
NodeId _nextSampleId() {
final id = _nodeSeed++;
return 'sample-$id';
}
void _setDrawColor(Color color) {
if (_controller.drawColor == color) return;
_controller.setDrawColor(color);
}
void _setSelectedTextColor(Color color) {
_updateSelectedTextNodes((node) {
node.color = color;
});
}
void _setSelectedTextAlign(TextAlign align) {
_updateSelectedTextNodes((node) {
node.align = align;
});
}
void _setSelectedTextFontSize(double value) {
_updateSelectedTextNodes((node) {
final lineHeightRatio = node.lineHeight == null
? null
: node.lineHeight! / node.fontSize;
node.fontSize = value;
if (lineHeightRatio != null) {
node.lineHeight = value * lineHeightRatio;
}
});
}
void _setSelectedTextLineHeight(double multiplier) {
_updateSelectedTextNodes((node) {
node.lineHeight = node.fontSize * multiplier;
});
}
void _toggleSelectedTextBold() {
final nodes = _selectedTextNodes();
if (nodes.isEmpty) return;
final next = !nodes.first.isBold;
_updateSelectedTextNodes((node) => node.isBold = next);
}
void _toggleSelectedTextItalic() {
final nodes = _selectedTextNodes();
if (nodes.isEmpty) return;
final next = !nodes.first.isItalic;
_updateSelectedTextNodes((node) => node.isItalic = next);
}
void _toggleSelectedTextUnderline() {
final nodes = _selectedTextNodes();
if (nodes.isEmpty) return;
final next = !nodes.first.isUnderline;
_updateSelectedTextNodes((node) => node.isUnderline = next);
}
void _setBackgroundColor(Color color) {
_controller.setBackgroundColor(color);
}
void _setGridEnabled(bool value) {
_controller.setGridEnabled(value);
}
void _setGridSize(double value) {
_controller.setGridCellSize(value);
}
void _setCameraX(double value) {
final camera = _controller.scene.camera;
_controller.setCameraOffset(Offset(value, camera.offset.dy));
}
}
class _ToolbarGroup extends StatelessWidget {
const _ToolbarGroup({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.labelSmall),
const SizedBox(height: 4),
child,
],
);
}
}
class _ColorPalette extends StatelessWidget {
const _ColorPalette({
required this.colors,
required this.selected,
required this.onSelected,
});
final List<Color> colors;
final Color selected;
final ValueChanged<Color> onSelected;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 6,
children: colors
.map(
(color) => _ColorSwatch(
color: color,
isSelected: color == selected,
onTap: () => onSelected(color),
),
)
.toList(growable: false),
);
}
}
class _ColorSwatch extends StatelessWidget {
const _ColorSwatch({
required this.color,
required this.isSelected,
required this.onTap,
});
final Color color;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final borderColor = isSelected
? Theme.of(context).colorScheme.onSurface
: Theme.of(
context,
).colorScheme.onSurface.withAlpha((0.2 * 255).round());
return InkResponse(
onTap: onTap,
radius: 18,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: borderColor, width: isSelected ? 2 : 1),
),
),
);
}
}
class _PendingLineMarkerPainter extends CustomPainter {
_PendingLineMarkerPainter({required this.controller})
: super(repaint: controller);
final SceneController controller;
@override
void paint(Canvas canvas, Size size) {
final start = controller.pendingLineStart;
if (start == null) return;
final viewPosition = toView(start, controller.scene.camera.offset);
final paint = Paint()
..color = controller.drawColor.withAlpha((0.8 * 255).round())
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(viewPosition, 8, paint);
canvas.drawLine(
viewPosition + const Offset(-12, 0),
viewPosition + const Offset(12, 0),
paint,
);
canvas.drawLine(
viewPosition + const Offset(0, -12),
viewPosition + const Offset(0, 12),
paint,
);
}
@override
bool shouldRepaint(covariant _PendingLineMarkerPainter oldDelegate) {
return oldDelegate.controller != controller;
}
}