Example of how to use Flutter Web in a Figma Pluigin

figma_flutter_plugin

This is an example of how to build a Figma plugin using Flutter web and inline all the assets in the single html file as required by the Figma plugin API.

Features

  • Tree shaking for Material Icons
  • Inline assets / fonts
  • Offline support in Figma (Not using remote views)
  • Two way communication between Flutter and Figma
  • Figma and FigJam support
  • Build scripts for generating figma project /build/figma
  • Typings for Figma API
  • Light and Dark mode support

Prerequisites

Plugin ID

You will need to get a new Figma plugin ID by opening Figma Desktop App and creating a new plugin. Then copy the ID to the figma/manifest.json and also update the name key.

Typings

Make sure to check out the repo with submodules if you want the Figma typings.

git submodule update --init --recursive

Flutter

You need to update the name and description in pubspec.yaml.

Figma

To use the plugin you need to import the manifest from the build/figma folder, not the top level figma folder.

Run the build script:

dart scripts/build.dart

Then open figma and import the manifest.json from the build/figma folder.

Screenshots

Figma

FigJam

Example

UI

import 'package:flutter/material.dart';

import 'figma.dart';

class Example extends StatefulWidget {
  const Example({
    super.key,
    required this.title,
    required this.seedColor,
    required this.isFigma,
  });

  final String title;
  final Color seedColor;
  final bool isFigma;

  @override
  State<Example> createState() => _ExampleState();
}

class _ExampleState extends State<Example> {
  final controller = TextEditingController(text: '5');
  final formKey = GlobalKey<FormState>();
  final api = FigmaApi();

  late int red = widget.seedColor.red;
  late int green = widget.seedColor.green;
  late int blue = widget.seedColor.blue;

  @override
  void initState() {
    if (widget.isFigma) {
      api.init().then((value) => setState(() {}));
    } else {
      api.initialized = true;
    }
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colors = theme.colorScheme;
    final color = Color.fromARGB(255, red, green, blue);
    final onColor = color.onColor();
    return Theme(
      data: theme.copyWith(
        colorScheme: ColorScheme.fromSeed(
          seedColor: color,
          brightness: colors.brightness,
        ),
      ),
      child: Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
          actions: [
            Builder(builder: (context) {
              return IconButton(
                icon: const Icon(Icons.settings_outlined),
                onPressed: () async {
                  showBottomSheet(
                    context: context,
                    builder: (context) {
                      return Material(
                        child: Column(
                          children: [
                            ListTile(
                              leading: Icon(Icons.notifications),
                              title: Text('Show notification'),
                              onTap: () => api.notify('Hello from Flutter!'),
                            ),
                            ListTile(
                              leading: Icon(Icons.close),
                              title: Text('Close Plugin'),
                              textColor: colors.error,
                              iconColor: colors.error,
                              onTap: () => api.closePlugin(),
                            ),
                          ],
                        ),
                      );
                    },
                  );
                },
              );
            }),
          ],
        ),
        body: !api.initialized
            ? const CircularProgressIndicator()
            : Form(
                key: formKey,
                autovalidateMode: AutovalidateMode.onUserInteraction,
                child: ListView(
                  padding: const EdgeInsets.all(16),
                  children: [
                    ListTile(
                      leading: const Icon(Icons.color_lens, color: Colors.red),
                      title: Text('Red'),
                      subtitle: Slider(
                        value: red.toDouble(),
                        min: 0,
                        max: 255,
                        divisions: 255,
                        onChanged: (value) =>
                            setState(() => red = value.toInt()),
                      ),
                    ),
                    const SizedBox(height: 16),
                    ListTile(
                      leading:
                          const Icon(Icons.color_lens, color: Colors.green),
                      title: Text('Green'),
                      subtitle: Slider(
                        value: green.toDouble(),
                        min: 0,
                        max: 255,
                        divisions: 255,
                        onChanged: (value) =>
                            setState(() => green = value.toInt()),
                      ),
                    ),
                    const SizedBox(height: 16),
                    ListTile(
                      leading: const Icon(Icons.color_lens, color: Colors.blue),
                      title: Text('Blue'),
                      subtitle: Slider(
                        value: blue.toDouble(),
                        min: 0,
                        max: 255,
                        divisions: 255,
                        onChanged: (value) =>
                            setState(() => blue = value.toInt()),
                      ),
                    ),
                    const SizedBox(height: 32),
                    TextFormField(
                      controller: controller,
                      decoration: const InputDecoration(
                        border: OutlineInputBorder(),
                        labelText: 'Number of rectangles',
                      ),
                      validator: (value) {
                        if (value == null || value.isEmpty) {
                          return 'Please enter a number';
                        }
                        if (int.tryParse(value) == null) {
                          return 'Please enter a valid number';
                        }
                        return null;
                      },
                    ),
                    const SizedBox(height: 16),
                    ElevatedButton.icon(
                      style: ElevatedButton.styleFrom(
                        backgroundColor: color,
                        foregroundColor: onColor,
                      ),
                      onPressed: () {
                        if (!formKey.currentState!.validate()) {
                          return;
                        }
                        formKey.currentState!.save();
                        api.createShapes(int.parse(controller.text), color);
                      },
                      label: const Text('Create rectangles'),
                      icon: const Icon(Icons.add, size: 18),
                    ),
                  ],
                ),
              ),
      ),
    );
  }
}

extension on FigmaApi {
  Future<void> createShapes(int count, Color color) async {
    final ids = <String>[];
    for (var i = 0; i < count; i++) {
      if (type == FigmaEditorType.figma) {
        final res = await execMethod(
          'createRectangle',
          attributes: {
            'x': i * 150,
            'fills': [
              {
                'type': 'SOLID',
                'color': color.toFigma(),
              },
            ],
          },
          keys: ['id', 'name'],
        );
        final result = res['result'] as Map;
        final id = result['id']?.toString();
        if (id != null) ids.add(id);
      } else {
        final res = await execMethod(
          'createShapeWithText',
          attributes: {
            'shapeType': FigJamShapeType.ROUNDED_RECTANGLE.name,
            'fills': [
              {
                'type': 'SOLID',
                'color': color.toFigma(),
              },
            ],
          },
          keys: ['id', 'name', 'width'],
        );
        print(res);
        final result = res['result'] as Map;
        final id = result['id']?.toString();
        if (id != null) {
          ids.add(id);
          final width = num.parse(result['width'].toString());
          await nodeOptions(id, attributes: {
            'x': i * (width + 200),
          });
        }
      }
    }

    if (type == FigmaEditorType.figJam) {
      for (var i = 0; i < ids.length - 1; i++) {
        await execMethod(
          'createConnector',
          attributes: {
            'strokeWeight': 8,
            'connectorStart': {
              'endpointNodeId': ids[i],
              'magnet': 'AUTO',
            },
            'connectorEnd': {
              'endpointNodeId': ids[i + 1],
              'magnet': 'AUTO',
            },
          },
        );
      }
    }

    await appendToCurrentPage(ids);
    await setSelection(ids);
    await scrollAndZoomIntoView(ids);
  }
}

Figma

import 'dart:async';
import 'dart:html' as html;

import 'package:flutter/material.dart';

class FigmaApi {
  FigmaEditorType type = FigmaEditorType.figma;
  String command = '';
  String pluginId = '';
  bool initialized = false;

  Future<void> init() async {
    final info = await _result('init');
    final result = info['result'] as Map;
    final editorType = (result['editorType'] ?? 'figma').toString();
    if (editorType == 'figma') {
      type = FigmaEditorType.figma;
    } else {
      type = FigmaEditorType.figJam;
    }
    command = result['command']?.toString() ?? '';
    pluginId = result['id']?.toString() ?? '';
    initialized = true;
    print('Editor: $type');
    print('Command: "$command"');
    print('Plugin ID: $pluginId');
  }

  Future<FigmaJson> execMethod(
    String name, {
    List<String> args = const [],
    FigmaJson attributes = const {},
    List<String>? keys,
  }) async {
    return _result('function', {
      'name': name,
      'attrs': attributes,
      'args': args,
      if (keys != null) 'keys': keys,
    });
  }

  Future<FigmaJson> notify(
    String message, {
    int? timeout,
    bool? error,
  }) async {
    return execMethod(
      'notify',
      args: [message],
      attributes: {
        if (timeout != null) 'timeout': timeout,
        if (error != null) 'error': error,
      },
    );
  }

  Future<FigmaJson> execCallback(
    String name, {
    FigmaJson attributes = const {},
  }) async {
    return _result(name, attributes);
  }

  Future<FigmaJson> nodeOptions(
    String nodeId, {
    FigmaJson attributes = const {},
    List<String>? keys,
  }) async {
    return _result('node-options', {
      'node_id': nodeId,
      'attrs': attributes,
      if (keys != null) 'keys': keys,
    });
  }

  Future<void> appendToCurrentPage(List<String> ids) async {
    await execCallback('append-to-current-page', attributes: {'ids': ids});
  }

  Future<void> setSelection(List<String> ids) async {
    await execCallback('set-selection', attributes: {'ids': ids});
  }

  Future<void> scrollAndZoomIntoView(List<String> ids) async {
    await execCallback('scroll-and-zoom-into-view', attributes: {'ids': ids});
  }

  Future<void> closePlugin([String? message]) async {
    await execMethod('closePlugin', args: [
      if (message != null) message,
    ]);
  }

  void _send(
    String type, [
    FigmaJson data = const {},
  ]) {
    final parent = html.window.parent!;
    final message = {
      'pluginMessage': {'msg_type': type, ...data}
    };
    parent.postMessage(message, '*');
  }

  void _receive(ValueChanged<FigmaJson> callback) {
    final parent = html.document.getElementById('output')!;
    parent.addEventListener('figma', (event) {
      final customEvent = event as html.CustomEvent;
      final detail = customEvent.detail;
      final result = detail;
      callback(result);
    });
  }

  Future<FigmaJson> _result(
    String type, [
    FigmaJson data = const {},
  ]) async {
    final completer = Completer<FigmaJson>();
    final id = DateTime.now().millisecondsSinceEpoch.toString();
    _receive((event) {
      if (event['id'] == id) {
        completer.complete(event);
      }
    });
    _send(type, {'id': id, ...data});
    return completer.future;
  }
}

extension FigmaColorUtils on Color {
  /// RGB values between 0 and 1
  Map<String, double> toFigma() {
    return {
      'r': red / 255,
      'g': green / 255,
      'b': blue / 255,
    };
  }

  String toHex() {
    final r = red.toRadixString(16).padLeft(2, '0');
    final g = green.toRadixString(16).padLeft(2, '0');
    final b = blue.toRadixString(16).padLeft(2, '0');
    return '#$r$g$b';
  }

  Color onColor() {
    return computeLuminance() > 0.5 ? Colors.black : Colors.white;
  }
}

typedef FigmaJson = Map<String, Object?>;

enum FigmaEditorType {
  figma,
  figJam,
}

enum FigJamShapeType {
  SQUARE,
  ELLIPSE,
  ROUNDED_RECTANGLE,
  DIAMOND,
  TRIANGLE_UP,
  TRIANGLE_DOWN,
  PARALLELOGRAM_RIGHT,
  PARALLELOGRAM_LEFT,
}

GitHub

View Github