canvas_editor_flutter 0.1.0
canvas_editor_flutter: ^0.1.0 copied to clipboard
Complete Flutter scene editor for canvas_core documents with turnkey UI and composable editing, asset, image-import, and export capabilities.
example/lib/main.dart
// Path: oss_packages/canvas_editor_flutter/example/lib/main.dart
import 'dart:convert';
import 'package:canvas_core/canvas_core_runtime.dart';
import 'package:canvas_editor_flutter/canvas_editor_flutter.dart';
import 'package:canvas_editor_flutter/asset_library.dart';
import 'package:canvas_editor_flutter/extensions.dart' show EditorShellConfig;
import 'package:canvas_editor_flutter/image_import.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
void main() {
runApp(const CanvasEditorExampleApp());
}
class CanvasEditorExampleApp extends StatelessWidget {
const CanvasEditorExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Canvas Editor Example',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: CanvasSceneEditor(
initialScene: _demoDocument,
resources: _demoResources,
shell: EditorShellConfig.standalone,
jsonExport: const JsonExportCapability(
output: _ExampleJsonOutputPort(),
availability: JsonExportAvailability(canCopy: true, canSave: false),
),
extensions: [
imageImportExtension<CanvasSceneDocument>(
imageImport: _demoImageImport,
),
canvasAssetLibraryExtension<CanvasSceneDocument>(
library: _demoAssetLibrary,
presentSelection: _presentDemoAssetSelection,
),
],
),
);
}
}
final _demoImageImport = _ExampleImageImportPort();
final _demoResources = CanvasRuntimeResources(
fonts: _DemoFontAssets(),
icons: _DemoIconCatalog(),
media: _ExampleCanvasMediaResolver(),
);
final CanvasAssetLibrary _demoAssetLibrary = LocalCanvasAssetLibrary(
<CanvasAssetLibraryItem>[
const CanvasAssetLibraryItem(
id: 'abstract-orange',
label: 'Abstract orange',
category: 'illustrations',
sourceRef: 'asset:assets/demo_assets/abstract_orange.png',
thumbnailRef: 'asset:assets/demo_assets/abstract_orange.png',
intrinsicSize: Size2D(320, 180),
tags: <String>['abstract', 'orange', 'landscape'],
),
const CanvasAssetLibraryItem(
id: 'geometric-blue',
label: 'Geometric blue',
category: 'illustrations',
sourceRef: 'asset:assets/demo_assets/geometric_blue.png',
thumbnailRef: 'asset:assets/demo_assets/geometric_blue.png',
intrinsicSize: Size2D(180, 320),
tags: <String>['geometric', 'blue', 'portrait'],
),
const CanvasAssetLibraryItem(
id: 'star-sticker',
label: 'Star sticker',
category: 'stickers',
sourceRef: 'asset:assets/demo_assets/star_sticker.png',
thumbnailRef: 'asset:assets/demo_assets/star_sticker.png',
intrinsicSize: Size2D(192, 192),
tags: <String>['sticker', 'star', 'square'],
),
],
);
Future<CanvasAssetLibraryItem?> _presentDemoAssetSelection(
BuildContext context,
CanvasAssetLibrary library,
) {
return showDialog<CanvasAssetLibraryItem>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Demo assets'),
content: SizedBox(
width: 360,
height: 340,
child: ListView.separated(
itemCount: library.items.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, index) {
final item = library.items[index];
return ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
_assetPathFromRef(item.thumbnailRef),
width: 48,
height: 48,
fit: BoxFit.contain,
errorBuilder: (_, _, _) {
return const SizedBox(
width: 48,
height: 48,
child: Icon(Icons.image_outlined),
);
},
),
),
title: Text(item.label),
subtitle: Text(item.category),
onTap: () => Navigator.of(dialogContext).pop(item),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
],
);
},
);
}
String _assetPathFromRef(String ref) {
final trimmed = ref.trim();
const prefix = 'asset:';
if (trimmed.startsWith(prefix)) {
return trimmed.substring(prefix.length).trim();
}
return trimmed;
}
const CanvasSceneDocument _demoDocument = CanvasSceneDocument(
artboardSize: Size2D(740, 420),
backgroundFill: CanvasFill.gradient(
LinearGradientSpec(
color1: 0xFFF8FAFC,
color2: 0xFFE0E7FF,
angle: 135,
width: 50,
),
),
backgroundOpacity: 1.0,
children: <Node>[
Node.path(
id: 'demo-card',
name: 'Demo card',
xf: Transform2D(position: Vec2(370, 210)),
data: PathData(
source: RoundRectSource(520, 220, 32, 32),
fill: CanvasFill.solid(0xFFFFFFFF),
strokeColor: 0xFFE0E7FF,
strokeWidth: 4,
),
),
Node.text(
id: 'demo-title',
name: 'Title',
xf: Transform2D(position: Vec2(370, 180)),
data: TextData(
text: 'CanvasSceneEditor',
fontFamily: 'Roboto',
fontWeight: 700,
fontSize: 42,
letterSpacing: 0,
fill: CanvasFill.solid(0xFF312E81),
),
),
Node.text(
id: 'demo-subtitle',
name: 'Subtitle',
xf: Transform2D(position: Vec2(370, 245)),
data: TextData(
text:
'Use Add → Image or Add → Assets, then edit the selected image '
'in the inspector.',
fontFamily: 'Roboto',
fontWeight: 400,
fontSize: 18,
letterSpacing: 0,
fill: CanvasFill.solid(0xFF475569),
),
),
],
);
final class _ExampleJsonOutputPort extends JsonOutputPort {
const _ExampleJsonOutputPort();
@override
Future<String> copyJson({required String json}) async {
await Clipboard.setData(ClipboardData(text: json));
return 'Scene JSON copied to clipboard.';
}
@override
Future<String> saveJson({
required String json,
required String filename,
}) async {
return 'Scene JSON saving is not available in this example.';
}
}
final class _ExampleImageImportPort implements ImageImportPort {
_ExampleImageImportPort({ImagePicker? picker})
: _picker = picker ?? ImagePicker();
final ImagePicker _picker;
@override
Future<ImageImportResult> importImage({
required ImageImportSource source,
}) async {
try {
final image = await _picker.pickImage(
source: switch (source) {
ImageImportSource.gallery => ImageSource.gallery,
ImageImportSource.camera => ImageSource.camera,
},
);
if (image == null) {
return const ImageImportResult.cancelled();
}
final bytes = await image.readAsBytes();
if (bytes.isEmpty) {
return ImageImportResult.failure(
'The selected image could not be read.',
);
}
final mimeType = _normalizedMimeType(image.mimeType);
// Example-only: return a directly renderable sourceRef without
// introducing a backend, upload API, or persistent media store.
final sourceRef = 'data:$mimeType;base64,${base64Encode(bytes)}';
return ImageImportResult.success(sourceRef);
} on Exception {
return ImageImportResult.failure(
'Unable to import image. Please try again.',
);
}
}
String _normalizedMimeType(String? value) {
final normalized = value?.trim().toLowerCase() ?? '';
if (normalized.startsWith('image/')) {
return normalized == 'image/jpg' ? 'image/jpeg' : normalized;
}
// canvas_renderer_flutter decodes the bytes through MemoryImage.
// The MIME value is best-effort in this lightweight example.
return 'application/octet-stream';
}
}
final class _DemoFontAssets implements CanvasFontAssets {
const _DemoFontAssets();
@override
Iterable<String> get fallbackFontFamilies => const <String>['Roboto'];
@override
List<FontDef> get loadableFonts => const <FontDef>[
FontDef(family: 'Roboto', label: 'Roboto'),
];
@override
List<FontDef> get pickerFonts => loadableFonts;
@override
Future<void> ensureLoaded(Iterable<String> families) async {
// The example uses Flutter's default Material font.
}
}
final class _DemoIconCatalog implements IconCatalogPort {
const _DemoIconCatalog();
@override
List<IconCatalogItem> get items => const <IconCatalogItem>[];
@override
Map<String, ResolvedIcon> get resolveMap => const <String, ResolvedIcon>{};
@override
ResolvedIcon? resolve(String iconRef) => null;
}
final class _ExampleCanvasMediaResolver implements CanvasMediaResolver {
const _ExampleCanvasMediaResolver();
@override
Future<Size2D?> resolveIntrinsicSize(String ref) async {
return _demoAssetLibrary.intrinsicSizeFor(ref);
}
@override
Future<Map<String, Size2D>> resolveIntrinsicSizes(List<String> refs) async {
final resolved = <String, Size2D>{};
for (final ref in refs) {
final size = await resolveIntrinsicSize(ref);
if (size != null) {
resolved[ref] = size;
}
}
return resolved;
}
@override
Future<String?> resolveUrl(String ref) async {
final trimmed = ref.trim();
if (trimmed.startsWith('data:')) {
return trimmed;
}
if (!trimmed.startsWith('asset:')) {
return null;
}
final path = _assetPathFromRef(trimmed);
return path.isEmpty ? null : path;
}
@override
Future<Map<String, String>> resolveUrls(List<String> refs) async {
final resolved = <String, String>{};
for (final ref in refs) {
final url = await resolveUrl(ref);
if (url != null) {
resolved[ref] = url;
}
}
return resolved;
}
}