How to use Flutter Web in a Figma Pluigin with example

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

Related Posts

Recent Posts

ഇടുക്കിയിലെ മലയോര മേഖലകളിൽ രാത്രിയാത്ര നിരോധിച്ചു. രാത്രി ഏഴു മുതൽ രാവിലെ ആറു വരെയാണ് നിരോധനം

ഏന്തയാർ ഈസ്റ്റിൽ പ്രളയത്തിൽ തകർന്ന പാലത്തിന് പകരം പുതിയ പാലം നിർമ്മിക്കുവാൻ താത്ക്കാലിക പാലം പൊളിച്ച് നീക്കി

Explore the Investment Opportunities: A Comprehensive Guide to Different Types of Mutual Funds

Title: Understanding Mutual Funds: A Beginner's Guide to Investing

തീവ്രമഴ മുന്നറിയിപ്പിന്റെ പശ്ചാതലത്തിൽ സംസ്ഥാനം ജാഗ്രതയിൽ

250,000 അപേക്ഷകൾ വർദ്ധിച്ചതിനാൽ ട്രാൻസ്‌പോർട്ട് കമ്മീഷണർ പരിശോധന പുനരാരംഭിക്കും

ഏലക്കയിൽ കീടനാശിനി സാന്നിധ്യം; ആറര ലക്ഷത്തിലധികം ടിൻ അരവണ നശിപ്പിക്കാൻ ടെൻഡർ ക്ഷണിച്ച് ദേവസ്വം ബോർഡ്‌

ഭീമൻ പാറക്കഷണങ്ങൾ അടർന്ന് ദേശീയ പാതയിലേക്ക് വീഴുന്നത് പതിവാകുന്നു. കുട്ടിക്കാനത്തിനും മുണ്ടക്കയത്തിനുമിടയിൽ നിലനിൽക്കുന്നത് വൻ അപകട ഭീഷണി

ചക്രവാതച്ചുഴി:അതിശക്തമായ മഴ വരുന്നു

പ്ലസ് വൺ പ്രവേശനം. അക്ഷയയിൽ തിക്കി തിരക്കേണ്ട, നെറ്റിവിറ്റി/ജാതി തെളിയിക്കാൻ പത്താംതരം സർട്ടിഫിക്കറ്റ് മതി