iwb_canvas_engine 0.2.0 copy "iwb_canvas_engine: ^0.2.0" to clipboard
iwb_canvas_engine: ^0.2.0 copied to clipboard

Scene-based canvas engine for Flutter: model, rendering, input, JSON serialization.

example/lib/main.dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:vector_math/vector_math_64.dart' show Vector3;
import 'package:iwb_canvas_engine/advanced.dart';

void main() {
  runApp(const CanvasExampleApp());
}

class CanvasExampleApp extends StatelessWidget {
  const CanvasExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'IWB Canvas Engine',
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: const Color(0xFF1565C0),
        brightness: Brightness.light,
      ),
      home: const CanvasExampleScreen(),
    );
  }
}

class CanvasExampleScreen extends StatefulWidget {
  const CanvasExampleScreen({super.key});

  @override
  State<CanvasExampleScreen> createState() => _CanvasExampleScreenState();
}

class _CanvasExampleScreenState extends State<CanvasExampleScreen> {
  late final SceneController _controller;
  int _sampleSeed = 0;
  int _nodeSeed = 0;
  String? _lastExportedJson;
  NodeId? _editingNodeId;
  TextEditingController? _textEditController;
  FocusNode? _textEditFocusNode;

  @override
  void initState() {
    super.initState();
    _controller = SceneController(
      scene: Scene(layers: [Layer()]),
      pointerSettings: const PointerInputSettings(
        tapSlop: 16,
        doubleTapSlop: 32,
        doubleTapMaxDelayMs: 450,
      ),
    );
    _controller.editTextRequests.listen(_beginInlineTextEdit);
  }

  @override
  void dispose() {
    _textEditController?.dispose();
    _textEditFocusNode?.dispose();
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFF5F5F7),
      body: SafeArea(
        child: AnimatedBuilder(
          animation: _controller,
          builder: (context, _) {
            return Stack(
              children: [
                // 1. Основной холст
                Positioned.fill(child: _buildCanvas()),

                // 2. Индикатор камеры (сверху слева)
                Positioned(top: 20, left: 20, child: _buildCameraIndicator()),

                // 3. Контекстное меню для текста (появляется над нижней панелью)
                if (_selectedTextNodes().isNotEmpty)
                  Positioned(
                    bottom: 120,
                    left: 20,
                    right: 20,
                    child: _buildTextOptionsPanel(),
                  ),

                // 4. ГЛАВНАЯ НИЖНЯЯ ПАНЕЛЬ (DOCK)
                Positioned(
                  bottom: 20,
                  left: 20,
                  right: 20,
                  child: _buildMainBottomBar(),
                ),

                // 5. Оверлей редактирования текста
                if (_editingNodeId != null)
                  _buildTextEditOverlay() ?? const SizedBox.shrink(),
              ],
            );
          },
        ),
      ),
    );
  }

  // --- UI СЕКЦИИ ---

  Widget _buildCameraIndicator() {
    final cameraX = _controller.scene.camera.offset.dx;
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      decoration: BoxDecoration(
        color: Colors.white.withValues(alpha: 0.8),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: Colors.black12),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Icon(Icons.videocam_outlined, size: 18),
          const SizedBox(width: 8),
          Text(
            "Camera X: ${cameraX.toStringAsFixed(0)}",
            style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
          ),
        ],
      ),
    );
  }

  Widget _buildMainBottomBar() {
    final isDrawMode = _controller.mode == CanvasMode.draw;
    final hasSelection = _controller.selectedNodeIds.isNotEmpty;

    return Container(
      height: 84,
      padding: const EdgeInsets.symmetric(horizontal: 12),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(24),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withValues(alpha: 0.15),
            blurRadius: 20,
            offset: const Offset(0, 4),
          ),
        ],
      ),
      child: Row(
        children: [
          // Режимы работы
          _buildModeToggle(),
          const VerticalDivider(indent: 20, endIndent: 20, width: 24),

          // Инструменты (динамические)
          Expanded(
            child: SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: Row(
                children: [
                  if (isDrawMode) ...[
                    _buildDrawToolButton(DrawTool.pen, Icons.brush, "Pen"),
                    _buildDrawToolButton(
                      DrawTool.highlighter,
                      Icons.border_color,
                      "Marker",
                    ),
                    _buildDrawToolButton(
                      DrawTool.line,
                      Icons.show_chart,
                      "Line",
                    ),
                    _buildDrawToolButton(
                      DrawTool.eraser,
                      Icons.auto_fix_normal,
                      "Eraser",
                    ),
                    const VerticalDivider(indent: 25, endIndent: 25, width: 20),
                    _ColorPalette(
                      colors: _controller.scene.palette.penColors,
                      selected: _controller.drawColor,
                      onSelected: _setDrawColor,
                    ),
                  ] else ...[
                    _buildActionButton(
                      Icons.rotate_left,
                      "Rotate L",
                      hasSelection
                          ? () => _controller.rotateSelection(clockwise: false)
                          : null,
                    ),
                    _buildActionButton(
                      Icons.rotate_right,
                      "Rotate R",
                      hasSelection
                          ? () => _controller.rotateSelection(clockwise: true)
                          : null,
                    ),
                    _buildActionButton(
                      Icons.flip,
                      "Flip V",
                      hasSelection
                          ? () => _controller.flipSelectionVertical()
                          : null,
                      quarterTurns: 1,
                    ),
                    _buildActionButton(
                      Icons.flip,
                      "Flip H",
                      hasSelection
                          ? () => _controller.flipSelectionHorizontal()
                          : null,
                    ),
                    _buildActionButton(
                      Icons.delete_outline,
                      "Delete",
                      hasSelection ? () => _controller.deleteSelection() : null,
                      color: Colors.red,
                    ),
                    _buildActionButton(
                      Icons.add_box_outlined,
                      "Add Sample",
                      _addSampleObjects,
                    ),
                  ],
                ],
              ),
            ),
          ),

          const VerticalDivider(indent: 20, endIndent: 20, width: 24),

          // Системные действия
          _buildGridMenu(),
          _buildSystemMenu(),
        ],
      ),
    );
  }

  Widget _buildModeToggle() {
    return Container(
      decoration: BoxDecoration(
        color: Colors.grey[100],
        borderRadius: BorderRadius.circular(16),
      ),
      padding: const EdgeInsets.all(4),
      child: Row(
        children: [
          _buildSmallModeBtn(CanvasMode.move, Icons.pan_tool_alt),
          _buildSmallModeBtn(CanvasMode.draw, Icons.edit),
        ],
      ),
    );
  }

  Widget _buildSmallModeBtn(CanvasMode mode, IconData icon) {
    final isSelected = _controller.mode == mode;
    return GestureDetector(
      onTap: () => _controller.setMode(mode),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
        decoration: BoxDecoration(
          color: isSelected ? Colors.white : Colors.transparent,
          borderRadius: BorderRadius.circular(12),
          boxShadow: isSelected
              ? [const BoxShadow(color: Colors.black12, blurRadius: 4)]
              : null,
        ),
        child: Icon(
          icon,
          color: isSelected ? Colors.blue[800] : Colors.grey[600],
        ),
      ),
    );
  }

  Widget _buildDrawToolButton(DrawTool tool, IconData icon, String label) {
    final isSelected = _controller.drawTool == tool;
    return IconButton(
      icon: Icon(icon),
      onPressed: () => _controller.setDrawTool(tool),
      color: isSelected ? Colors.blue : Colors.grey[700],
      iconSize: 28,
      tooltip: label,
    );
  }

  Widget _buildActionButton(
    IconData icon,
    String label,
    VoidCallback? onTap, {
    Color? color,
    int quarterTurns = 0,
  }) {
    return IconButton(
      icon: RotatedBox(
        quarterTurns: quarterTurns,
        child: Icon(
          icon,
          color: onTap == null ? Colors.grey[300] : (color ?? Colors.grey[800]),
        ),
      ),
      onPressed: onTap,
      tooltip: label,
      iconSize: 28,
    );
  }

  Widget _buildTextOptionsPanel() {
    final nodes = _selectedTextNodes();
    if (nodes.isEmpty) return const SizedBox.shrink();
    final node = nodes.first;

    return Card(
      elevation: 6,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          child: Row(
            children: [
              _buildTextStyleToggle(
                Icons.format_bold,
                node.isBold,
                _toggleSelectedTextBold,
              ),
              _buildTextStyleToggle(
                Icons.format_italic,
                node.isItalic,
                _toggleSelectedTextItalic,
              ),
              _buildTextStyleToggle(
                Icons.format_underline,
                node.isUnderline,
                _toggleSelectedTextUnderline,
              ),
              const VerticalDivider(width: 20),
              _buildAlignSelector(node.align),
              const VerticalDivider(width: 20),
              const Text(
                "Size: ",
                style: TextStyle(fontWeight: FontWeight.bold),
              ),
              Slider(
                value: node.fontSize.clamp(10, 72).toDouble(),
                min: 10,
                max: 72,
                divisions: 10,
                onChanged: _setSelectedTextFontSize,
              ),
              const VerticalDivider(width: 20),
              const Text(
                "Line Height: ",
                style: TextStyle(fontWeight: FontWeight.bold),
              ),
              Slider(
                value: (node.lineHeight ?? node.fontSize * 1.2).clamp(
                  node.fontSize * 0.8,
                  node.fontSize * 3.0,
                ),
                min: node.fontSize * 0.8,
                max: node.fontSize * 3.0,
                divisions: 20,
                label: node.lineHeight == null
                    ? 'Auto'
                    : node.lineHeight!.toStringAsFixed(1),
                onChanged: _setSelectedTextLineHeight,
              ),
              const VerticalDivider(width: 20),
              _ColorPalette(
                colors: _controller.scene.palette.penColors,
                selected: node.color,
                onSelected: _setSelectedTextColor,
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildTextStyleToggle(IconData icon, bool active, VoidCallback onTap) {
    return IconButton(
      icon: Icon(icon),
      color: active ? Colors.blue : Colors.grey,
      onPressed: onTap,
    );
  }

  Widget _buildAlignSelector(TextAlign current) {
    return Row(
      children: [TextAlign.left, TextAlign.center, TextAlign.right].map((a) {
        return IconButton(
          icon: Icon(
            a == TextAlign.left
                ? Icons.format_align_left
                : a == TextAlign.center
                ? Icons.format_align_center
                : Icons.format_align_right,
          ),
          color: current == a ? Colors.blue : Colors.grey,
          onPressed: () => _setSelectedTextAlign(a),
        );
      }).toList(),
    );
  }

  // --- ЛОГИКА ДИАЛОГОВ ---

  Widget _buildGridMenu() {
    final grid = _controller.scene.background.grid;
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    return MenuAnchor(
      alignmentOffset: const Offset(-240, 0),
      style: MenuStyle(
        padding: WidgetStateProperty.all(
          const EdgeInsets.symmetric(vertical: 12),
        ),
        shape: WidgetStateProperty.all(
          RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
        ),
        elevation: WidgetStateProperty.all(12),
      ),
      builder: (context, controller, child) {
        return IconButton(
          icon: Icon(
            grid.isEnabled ? Icons.grid_4x4 : Icons.grid_off,
            color: grid.isEnabled
                ? colorScheme.primary
                : colorScheme.onSurfaceVariant,
          ),
          onPressed: () =>
              controller.isOpen ? controller.close() : controller.open(),
          tooltip: "Grid Settings",
          iconSize: 28,
        );
      },
      menuChildren: [
        SizedBox(
          width: 300,
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                Row(
                  children: [
                    Container(
                      padding: const EdgeInsets.all(8),
                      decoration: BoxDecoration(
                        color: colorScheme.primaryContainer.withValues(
                          alpha: 0.4,
                        ),
                        shape: BoxShape.circle,
                      ),
                      child: Icon(
                        Icons.grid_on,
                        size: 18,
                        color: colorScheme.primary,
                      ),
                    ),
                    const SizedBox(width: 12),
                    Text(
                      "Grid Appearance",
                      style: theme.textTheme.titleMedium?.copyWith(
                        color: colorScheme.onSurface,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 24),
                Row(
                  children: [
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(
                            "Display Grid",
                            style: theme.textTheme.bodyLarge?.copyWith(
                              fontWeight: FontWeight.w600,
                            ),
                          ),
                          const SizedBox(height: 2),
                          Text(
                            "Enable alignment guides",
                            style: theme.textTheme.bodySmall?.copyWith(
                              color: colorScheme.onSurfaceVariant,
                            ),
                          ),
                        ],
                      ),
                    ),
                    Switch(
                      value: grid.isEnabled,
                      onChanged: (v) {
                        _setGridEnabled(v);
                        setState(() {});
                      },
                    ),
                  ],
                ),
                const SizedBox(height: 24),
                Text(
                  "Cell Size",
                  style: theme.textTheme.labelMedium?.copyWith(
                    color: colorScheme.onSurfaceVariant,
                    fontWeight: FontWeight.bold,
                    letterSpacing: 0.5,
                  ),
                ),
                const SizedBox(height: 12),
                SizedBox(
                  width: double.infinity,
                  child: SegmentedButton<double>(
                    showSelectedIcon: false,
                    segments: _controller.scene.palette.gridSizes.map((s) {
                      return ButtonSegment<double>(
                        value: s,
                        label: Text(
                          s.toInt().toString(),
                          style: const TextStyle(fontWeight: FontWeight.bold),
                        ),
                      );
                    }).toList(),
                    selected: {grid.cellSize},
                    onSelectionChanged: (Set<double> newSelection) {
                      _setGridSize(newSelection.first);
                      setState(() {});
                    },
                    style: SegmentedButton.styleFrom(
                      visualDensity: VisualDensity.comfortable,
                      selectedBackgroundColor: colorScheme.primary,
                      selectedForegroundColor: colorScheme.onPrimary,
                    ),
                  ),
                ),
                const SizedBox(height: 12),
              ],
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildSystemMenu() {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    return MenuAnchor(
      alignmentOffset: const Offset(-240, 0),
      style: MenuStyle(
        padding: WidgetStateProperty.all(
          const EdgeInsets.symmetric(vertical: 12),
        ),
        shape: WidgetStateProperty.all(
          RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
        ),
        elevation: WidgetStateProperty.all(12),
      ),
      builder: (context, controller, child) {
        return IconButton(
          icon: Icon(Icons.settings, color: colorScheme.onSurfaceVariant),
          onPressed: () =>
              controller.isOpen ? controller.close() : controller.open(),
          tooltip: "System Menu",
          iconSize: 28,
        );
      },
      menuChildren: [
        SizedBox(
          width: 280,
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Padding(
                padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
                child: Row(
                  children: [
                    Icon(
                      Icons.palette_outlined,
                      size: 18,
                      color: colorScheme.primary,
                    ),
                    const SizedBox(width: 12),
                    Text(
                      "Background",
                      style: theme.textTheme.titleSmall?.copyWith(
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ],
                ),
              ),
              SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                padding: const EdgeInsets.symmetric(
                  horizontal: 20,
                  vertical: 8,
                ),
                child: Row(
                  children: _controller.scene.palette.backgroundColors.map((c) {
                    final isSelected = _controller.scene.background.color == c;
                    return Padding(
                      padding: const EdgeInsets.only(right: 8),
                      child: GestureDetector(
                        onTap: () {
                          _setBackgroundColor(c);
                          setState(() {});
                        },
                        child: Container(
                          width: 32,
                          height: 32,
                          decoration: BoxDecoration(
                            color: c,
                            shape: BoxShape.circle,
                            border: Border.all(
                              color: isSelected
                                  ? colorScheme.primary
                                  : Colors.black12,
                              width: isSelected ? 2 : 1,
                            ),
                            boxShadow: isSelected
                                ? [
                                    BoxShadow(
                                      color: colorScheme.primary.withValues(
                                        alpha: 0.3,
                                      ),
                                      blurRadius: 4,
                                    ),
                                  ]
                                : null,
                          ),
                          child: isSelected
                              ? Icon(
                                  Icons.check,
                                  size: 16,
                                  color: c.computeLuminance() > 0.5
                                      ? Colors.black
                                      : Colors.white,
                                )
                              : null,
                        ),
                      ),
                    );
                  }).toList(),
                ),
              ),
              const Divider(indent: 16, endIndent: 16),
              MenuItemButton(
                leadingIcon: const Icon(Icons.download_outlined, size: 20),
                onPressed: _exportSceneJson,
                child: const Text("Export (JSON)"),
              ),
              MenuItemButton(
                leadingIcon: const Icon(Icons.upload_outlined, size: 20),
                onPressed: _importSceneJson,
                child: const Text("Import (JSON)"),
              ),
              const Divider(indent: 16, endIndent: 16),
              MenuItemButton(
                leadingIcon: Icon(
                  Icons.delete_sweep_outlined,
                  color: colorScheme.error,
                  size: 20,
                ),
                onPressed: () => _controller.clearScene(),
                child: Text(
                  "Clear Canvas",
                  style: TextStyle(
                    color: colorScheme.error,
                    fontWeight: FontWeight.w600,
                  ),
                ),
              ),
              const SizedBox(height: 8),
            ],
          ),
        ),
      ],
    );
  }

  // --- БАЗОВЫЕ МЕТОДЫ (СОХРАНЕНЫ ИЗ ОРИГИНАЛА) ---

  Widget _buildCanvas() {
    return Stack(
      children: [
        SceneView(
          controller: _controller,
          imageResolver: (_) => null,
          selectionColor: const Color(0xFFFFFF00),
          selectionStrokeWidth: 4,
        ),
        Positioned.fill(
          child: IgnorePointer(
            child: CustomPaint(
              painter: _PendingLineMarkerPainter(controller: _controller),
            ),
          ),
        ),
      ],
    );
  }

  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();
  }

  TextNode? _findTextNode(NodeId id) {
    for (final layer in _controller.scene.layers) {
      for (final node in layer.nodes) {
        if (node is TextNode && node.id == id) return node;
      }
    }
    return null;
  }

  void _beginInlineTextEdit(EditTextRequested request) {
    if (_editingNodeId != null) return;
    final node = _findTextNode(request.nodeId);
    if (node == null) return;
    setState(() {
      _editingNodeId = node.id;
      node.isVisible = false;
      _textEditController = TextEditingController(text: node.text);
      _textEditFocusNode = FocusNode();
    });
    _controller.notifySceneChanged();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted) return;
      _textEditFocusNode?.requestFocus();
    });
  }

  void _finishInlineTextEdit({required bool save}) {
    final nodeId = _editingNodeId;
    if (nodeId == null) return;

    final node = _findTextNode(nodeId);
    if (node != null) {
      if (save) {
        final newText = _textEditController?.text ?? "";
        node.text = newText;

        // Update node size to fit the text precisely
        final textStyle = TextStyle(
          fontSize: node.fontSize,
          fontWeight: node.isBold ? FontWeight.bold : FontWeight.normal,
          fontStyle: node.isItalic ? FontStyle.italic : FontStyle.normal,
          fontFamily: node.fontFamily,
          height: node.lineHeight == null
              ? null
              : node.lineHeight! / node.fontSize,
        );
        final tp = TextPainter(
          text: TextSpan(text: newText, style: textStyle),
          textDirection: TextDirection.ltr,
          textAlign: node.align,
        )..layout();

        // Exact size matches prevent jumping back
        node.size = Size(tp.width, tp.height);
      }
      node.isVisible = true;
      _controller.notifySceneChanged();
    }

    setState(() {
      _editingNodeId = null;
      _textEditController?.dispose();
      _textEditFocusNode?.dispose();
    });
  }

  Widget? _buildTextEditOverlay() {
    final nodeId = _editingNodeId;
    if (nodeId == null || _textEditController == null) return null;
    final node = _findTextNode(nodeId);
    if (node == null) return null;

    final viewPosition = toView(node.position, _controller.scene.camera.offset);
    final alignment = _mapTextAlignToAlignment(node.align);

    return Positioned(
      left: viewPosition.dx,
      top: viewPosition.dy,
      child: Transform(
        transform: Matrix4.rotationZ(
          node.rotationDeg * math.pi / 180,
        ).scaledByVector3(Vector3(node.scaleX, node.scaleY, 1.0)),
        child: FractionalTranslation(
          translation: const Offset(-0.5, -0.5),
          child: SizedBox(
            width: node.size.width,
            height: node.size.height,
            child: OverflowBox(
              maxWidth: 3000,
              maxHeight: 2000,
              alignment: alignment,
              child: TextField(
                controller: _textEditController,
                focusNode: _textEditFocusNode,
                maxLines: null,
                textAlign: node.align,
                scrollPadding: EdgeInsets.zero,
                onTapOutside: (_) => _finishInlineTextEdit(save: true),
                strutStyle: StrutStyle(
                  fontSize: node.fontSize,
                  height: node.lineHeight == null
                      ? null
                      : node.lineHeight! / node.fontSize,
                  forceStrutHeight: true,
                ),
                style: TextStyle(
                  fontSize: node.fontSize,
                  color: node.color,
                  fontWeight: node.isBold ? FontWeight.bold : FontWeight.normal,
                  fontStyle: node.isItalic
                      ? FontStyle.italic
                      : FontStyle.normal,
                  decoration: node.isUnderline
                      ? TextDecoration.underline
                      : null,
                  fontFamily: node.fontFamily,
                  height: node.lineHeight == null
                      ? null
                      : node.lineHeight! / node.fontSize,
                ),
                decoration: const InputDecoration(
                  border: InputBorder.none,
                  isDense: true,
                  contentPadding: EdgeInsets.zero,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }

  Alignment _mapTextAlignToAlignment(TextAlign align) {
    switch (align) {
      case TextAlign.center:
        return Alignment.center;
      case TextAlign.right:
        return Alignment.centerRight;
      default:
        return Alignment.centerLeft;
    }
  }

  Future<void> _exportSceneJson() async {
    final json = encodeSceneToJson(_controller.scene);
    _lastExportedJson = json;
    await showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Scene JSON'),
        content: SizedBox(
          width: 400,
          child: TextField(
            controller: TextEditingController(text: json),
            maxLines: 8,
            readOnly: true,
          ),
        ),
        actions: [
          TextButton(
            onPressed: () {
              Clipboard.setData(ClipboardData(text: json));
              Navigator.pop(context);
            },
            child: const Text('Copy'),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }

  Future<void> _importSceneJson() async {
    final controller = TextEditingController(text: _lastExportedJson ?? '');
    final result = await showDialog<String>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Import Scene'),
        content: TextField(controller: controller, maxLines: 8),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(context, controller.text),
            child: const Text('Import'),
          ),
        ],
      ),
    );
    if (result != null && result.isNotEmpty) {
      try {
        final decoded = decodeSceneFromJson(result);
        _applyDecodedScene(decoded);
        _controller.notifySceneChanged();
      } catch (e) {
        if (!mounted) return;
        ScaffoldMessenger.of(
          context,
        ).showSnackBar(SnackBar(content: Text('Error: $e')));
      }
    }
  }

  void _applyDecodedScene(Scene decoded) {
    final scene = _controller.scene;
    scene.layers
      ..clear()
      ..addAll(decoded.layers);
    scene.camera.offset = decoded.camera.offset;
    scene.background.color = decoded.background.color;
    scene.background.grid.isEnabled = decoded.background.grid.isEnabled;
    scene.background.grid.cellSize = decoded.background.grid.cellSize;
  }

  void _addSampleObjects() {
    final layerIndex = 0;
    final baseX = 100 + (_sampleSeed * 30);
    final baseY = 100 + (_sampleSeed * 20);

    final nodes = <SceneNode>[
      RectNode(
        id: 'sample-${_nodeSeed++}',
        size: const Size(140, 90),
        fillColor: Colors.blue.withValues(alpha: 0.2),
        strokeColor: Colors.blue,
        strokeWidth: 2,
      )..position = Offset(baseX.toDouble(), baseY.toDouble()),
    ];

    // Calculate proper size for text node
    final textNode = TextNode(
      id: 'sample-${_nodeSeed++}',
      text: 'New Note',
      size: const Size(100, 30), // temporary
      fontSize: 20,
      color: Colors.black87,
    );

    final textPainter = TextPainter(
      text: TextSpan(
        text: textNode.text,
        style: TextStyle(
          fontSize: textNode.fontSize,
          fontWeight: textNode.isBold ? FontWeight.bold : FontWeight.normal,
          fontStyle: textNode.isItalic ? FontStyle.italic : FontStyle.normal,
          fontFamily: textNode.fontFamily,
          height: textNode.lineHeight == null
              ? null
              : textNode.lineHeight! / textNode.fontSize,
        ),
      ),
      textDirection: TextDirection.ltr,
      textAlign: textNode.align,
    )..layout();

    textNode.size = Size(textPainter.width, textPainter.height);
    textNode.position = Offset(baseX + 160, baseY.toDouble());
    nodes.add(textNode);

    _sampleSeed++;
    for (final node in nodes) {
      _controller.addNode(node, layerIndex: layerIndex);
    }
  }

  // Сеттеры
  void _setDrawColor(Color c) => _controller.setDrawColor(c);
  void _setBackgroundColor(Color c) => _controller.setBackgroundColor(c);
  void _setGridEnabled(bool v) => _controller.setGridEnabled(v);
  void _setGridSize(double s) => _controller.setGridCellSize(s);
  void _setSelectedTextColor(Color c) =>
      _updateSelectedTextNodes((n) => n.color = c);
  void _setSelectedTextAlign(TextAlign a) =>
      _updateSelectedTextNodes((n) => n.align = a);
  void _setSelectedTextFontSize(double v) =>
      _updateSelectedTextNodes((n) => n.fontSize = v);
  void _setSelectedTextLineHeight(double v) =>
      _updateSelectedTextNodes((n) => n.lineHeight = v);
  void _toggleSelectedTextBold() =>
      _updateSelectedTextNodes((n) => n.isBold = !n.isBold);
  void _toggleSelectedTextItalic() =>
      _updateSelectedTextNodes((n) => n.isItalic = !n.isItalic);
  void _toggleSelectedTextUnderline() =>
      _updateSelectedTextNodes((n) => n.isUnderline = !n.isUnderline);
}

// --- ВСПОМОГАТЕЛЬНЫЕ ВИДЖЕТЫ ---

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: 8,
      children: colors
          .map(
            (color) => GestureDetector(
              onTap: () => onSelected(color),
              child: Container(
                width: 32,
                height: 32,
                decoration: BoxDecoration(
                  color: color,
                  shape: BoxShape.circle,
                  border: Border.all(
                    color: color == selected ? Colors.black : Colors.black12,
                    width: color == selected ? 3 : 1,
                  ),
                  boxShadow: color == selected
                      ? [
                          BoxShadow(
                            color: color.withValues(alpha: 0.4),
                            blurRadius: 4,
                          ),
                        ]
                      : null,
                ),
              ),
            ),
          )
          .toList(),
    );
  }
}

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 viewPos = toView(start, controller.scene.camera.offset);
    final paint = Paint()
      ..color = controller.drawColor.withAlpha(200)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;
    canvas.drawCircle(viewPos, 12, paint);
    canvas.drawLine(
      viewPos + const Offset(-15, 0),
      viewPos + const Offset(15, 0),
      paint,
    );
    canvas.drawLine(
      viewPos + const Offset(0, -15),
      viewPos + const Offset(0, 15),
      paint,
    );
  }

  @override
  bool shouldRepaint(covariant _PendingLineMarkerPainter oldDelegate) =>
      oldDelegate.controller != controller;
}
0
likes
160
points
96
downloads

Publisher

unverified uploader

Weekly Downloads

Scene-based canvas engine for Flutter: model, rendering, input, JSON serialization.

Repository (GitHub)
View/report issues

Topics

#flutter #canvas #whiteboard #drawing #graphics

Documentation

Documentation
API reference

License

MIT (license)

Dependencies

flutter, path_drawing

More

Packages that depend on iwb_canvas_engine