Skip to content

Repository files navigation

Novident Editor

A high-performance rich-text editor for Flutter — part of the Novident suite.

pub package License: MPL 2.0 GitHub issues

Fork of AppFlowy Editor, used under the Mozilla Public License 2.0. See NOTICE for attribution.

Novident Editor is a drop-in rich-text editor for Flutter. It renders a document tree built from composable block components — paragraphs, headings, lists, quotes, images and tables — with vim emulation, named paragraph styles, zen mode and spell checking out of the box.

Quick start

import 'package:novident_editor/novident_editor.dart';
import 'package:flutter_localizations/flutter_localizations.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      localizationsDelegates: const [
        NovidentEditorLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      home: Scaffold(
        body: NovidentEditor(
          editorState: EditorState.blank(withInitialText: true),
        ),
      ),
    );
  }
}

Core concepts

The editor is built around three layers:

Document  →  the tree of nodes (paragraphs, headings, tables…) holding
             rich-text Deltas and attributes. Pure data, serializable.
EditorState  →  the single mutable state: document, selection, styles,
             services (undo, spell check, word count). Created once,
             passed to the editor widget.
NovidentEditor  →  the widget. Renders the document, forwards input, and
             reflects every change back into the EditorState.

Everything you do programmatically goes through EditorState.

Read and write content

final editorState = EditorState.blank(withInitialText: true);
final node = editorState.document.first!;

// Write: insert text at offset 0 (with attributes) through a transaction
editorState.apply(
  editorState.transaction
    ..insertText(node, 0, 'Hello ', attributes: {'bold': true})
    ..insertText(node, 6, 'Novident'),
);

// Format a range
editorState.apply(
  editorState.transaction
    ..formatText(node, 0, 5, {'italic': true}),
);

// Read: plain text, delta, or the whole document as JSON
print(node.delta!.toPlainText());       // "Hello Novident"
print(editorState.document.toJson());   // persistable structure

// Selection, undo/redo
editorState.selection = Selection.collapsed(
  Position(path: node.path, offset: 5),
);
editorState.undoManager.undo(collapseSelection: false);
editorState.undoManager.redo(collapseSelection: true);

Hydrate the editor from JSON, Markdown, or Quill Delta — see Importing content.

Features

Named paragraph styles

A named style system with basedOn inheritance — define a base style once and every derived style inherits font, size, spacing and colours, resolved through a three-tier fallback (explicit style → type default → global default):

final baseStyle = NovidentStyleDefinition(
  id: 'base',
  name: 'Base',
  fontSize: 12,
  fontFamily: 'Arial',
  indent: NovidentStyleIndent.defaultLineFilter(),
);

final editor = NovidentEditor(
  editorState: editorState,
  styles: NovidentStylesConfig(
    defaultStyle: baseStyle,
    defaultStylesByType: <String, NovidentStyleDefinition>{
      'table': kDefaultTableStyle,
    },
    registry: NovidentStyleRegistry({
      baseStyle.id: baseStyle,
      'body': NovidentStyleDefinition.nextSame(
        id: 'body',
        name: 'Body',
        basedOn: 'base',
        spacing: NovidentStyleSpacing(after: 8),
      ),
      'heading-1': NovidentStyleDefinition(
        id: 'heading-1',
        name: 'Heading 1',
        basedOn: 'base',
        fontSize: 32,
        bold: true,
        spacing: NovidentStyleSpacing(before: 24, after: 12),
        next: 'body',
      ),
    }),
  ),
);

Important

Default styles require fontSize, fontFamily and textColor to be defined.

Toolbar items (styleToolbarItem, buildFontFamilyItem, buildFontSizeItem) resolve the current value through the same chain. See Styles — full guide.

Tables

Tables render with a weight-based column layout that fills the available width, with reusable style definitions for zebra striping, coloured headers and borderless layouts:

final table = TableNode.fromList([
  ['Name', 'Elara'],   // column 0
  ['Role', 'Mage'],    // column 1
]);
editorState.apply(editorState.transaction..insertNode(path, table.node));

See Tables — full guide.

Vim emulation

Built into the package — no extra dependency. Every keybinding is remappable at runtime without rebuilding the editor:

final vimController = VimModeController();

NovidentEditor(
  editorState: editorState,
  editorStyle: EditorStyle.desktop(
    // Block cursor in normal/visual mode; thin caret in insert mode.
    selectionRenderer: VimSelectionRenderer(controller: vimController),
  ),
  keyboardStrategies: [
    VimStrategy(
      session.vimController,
    ),
    DefaultEditorStrategy(
      commandShortcutEvents: [
        ...session.vimController.commandShortcutEvents,
        ...tableCommands,
        ...standardCommandShortcutEvents,
      ],
      characterShortcutEvents: standardCharacterShortcutEvents,
    ),
  ],
);

vimController.attach(editorState);
vimController.configuration =
    vimController.configuration.rebind(VimCommand.moveLeft, 'a', rawCommand: null);

See Vim Commands for the full command set, custom commands, and cursor styling.

Zen mode

Dims unfocused blocks, neutralises text/block colours without touching the document, and keeps the focused block vertically centered (typewriter scroll):

final zenController = ZenModeController();
zenController.attach(
  editorState: editorState,
  scrollController: editorScrollController,
);

final editor = NovidentEditor(
  editorState: editorState,
  editorScrollController: editorScrollController,
  blockWrapper: zenController.blockWrapper,
);

The dimming is applied through the span pipeline: attach registers a ZenSpanPipeline wrapper on the EditorState that composes over the effective pipeline (spell check or default).

Typewriter mode

Keeps the cursor vertically centered in the viewport as you type (typewriter scrolling). It is independent from zen mode — pass a TypewriterScrollStrategy to NovidentEditor.scrollStrategies:

final editor = NovidentEditor(
  editorState: editorState,
  editorScrollController: editorScrollController,
  scrollStrategies: const [
    TypewriterScrollStrategy(
      alignment: 0.45, // 0.0 = top, 0.5 = center, 1.0 = bottom
    ),
  ],
);

The strategy keeps the cursor centered instantly (no animation, so no ping-pong) for collapsed selections, and delegates to the default edge-follow for expanded selections (e.g. while selecting text). When the caret is at the very top or bottom of the document (no content to scroll into), the scroll clamps and the caret is not centered.

To disable the centering (the editor keeps the native caret auto-scroll), pass an empty list:

NovidentEditor(
  editorState: editorState,
  scrollStrategies: const [],
);

Note

Typewriter scrolling is now a standalone feature that works with or without zen mode. Zen only dims unfocused blocks; the scroll strategy only scrolls.

See Scroll Strategies for the full scrollStrategies API — how dispatch works, the ScrollStrategy interface, and how to write your own scroll policy.

Spell checking

Spell checking plugs in through the engine-agnostic novident_spell_check_interface contract. The editor runs the analysis out of band (after typing inactivity), stores the marks in the document delta, and the render pipeline underlines them — no checker call ever runs inside build:

final checker = MySpellChecker(); // implements NovidentSpellChecker

final editor = NovidentEditor(
  editorState: editorState,
  editorStyle: EditorStyle.desktop(
    spellChecker: checker,
    spellCheckMisspelledStyle: const TextStyle(
      decoration: TextDecoration.underline,
      decorationStyle: TextDecorationStyle.wavy,
      decorationColor: Colors.red,
    ),
    // Idle debounce before re-analysis (default 600 ms)
    spellCheckDebounce: const Duration(milliseconds: 600),
  ),
);
class MySpellChecker extends NovidentSpellChecker {
  final Set<String> dictionary = {'hello', 'world'};

  @override String? get language => 'en';

  @override
  bool isValid(String word) => dictionary.contains(word.toLowerCase());

  @override
  List<SpellCheckIssue> check(String text) { /* tokenize + validate */ }

  @override
  List<String> suggest(String word) =>
      dictionary.where((w) => w.startsWith(word[0])).toList();
}

The reference implementation lives in the example app: a full Hunspell en_US checker whose parsing, affix expansion and suggestion index live in a worker isolate (example/lib/spell_check/), so even the whole dictionary never touches the UI isolate. Suggestions reach context menus through the async suggestAsync contract entry point.

Word & character counter

final counter = WordCountService(editorState: editorState)..register();

ListenableBuilder(
  listenable: counter,
  builder: (context, _) => Text(
    '${counter.documentCounters.wordCount} words  '
    '${counter.documentCounters.charCount} chars',
  ),
);

Customising block components

Override a built-in block or register a new one through blockComponentBuilders:

final editor = NovidentEditor(
  editorState: editorState,
  blockComponentBuilders: {
    ...standardBlockComponentBuilderMap,
    'my_custom_type': MyCustomBlockBuilder(),
  },
);

See documentation/customizing.md for a detailed walkthrough.

Complete example

A realistic setup combining styles, spell checking and a word counter:

import 'package:flutter/material.dart';
import 'package:novident_editor/novident_editor.dart';

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

  @override
  State<EditorPage> createState() => _EditorPageState();
}

class _EditorPageState extends State<EditorPage> {
  final EditorState editorState = EditorState.blank(withInitialText: true);
  late final WordCountService _counter = WordCountService(
    editorState: editorState,
  )..register();

  @override
  void dispose() {
    _counter.dispose();
    editorState.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: NovidentEditor(
            editorState: editorState,
            editorStyle: EditorStyle.desktop(
              spellChecker: MySpellChecker(),
              spellCheckMisspelledStyle: const TextStyle(
                decoration: TextDecoration.underline,
                decorationStyle: TextDecorationStyle.wavy,
                decorationColor: Colors.red,
              ),
            ),
          ),
        ),
        ListenableBuilder(
          listenable: _counter,
          builder: (context, _) => Text(
            '${_counter.documentCounters.wordCount} words  '
            '${_counter.documentCounters.charCount} chars',
          ),
        ),
      ],
    );
  }
}

Packages

The editor's core layers are published as standalone packages — use them when you need one piece without the full editor:

Package What it provides Use it for
novident_document Document tree, rich-text Deltas, delta-change events Document models, storage, offline-first apps
novident_editor_core Core editor primitives (RichText rendering base) Low-level rendering work
novident_editor_styles Style definitions and resolution Custom style engines
novident_editor_quill_parser Encoder and Decoder of Quill Delta Export or Import Delta/Nodes in both directions
novident_editor_selection Selection model, renderers, painters Custom selection UIs
novident_editor_rich_text NovidentRichText + the 6-phase span pipeline Paragraph rendering, custom decorations
novident_editor_spell_check_interface The spell-checker contract (NovidentSpellChecker) Building or swapping spell-check engines

Documentation

  • Styles — named styles, basedOn resolution, font provider, toolbar items
  • Tables — creation, styling, weights, shortcuts
  • Vim commands — the command set, remapping, custom commands
  • Customising blocks — overriding and creating block components
  • Importing content — JSON, Markdown, Quill Delta
  • Testing — the test helpers used across the project

Migrating

  • If you were using version 1.0.5, see migration to 1.0.6 — zen mode dimming moved to the span pipeline and typewriter scrolling became a standalone feature.
  • If you were using version 1.0.4, see migration to 1.0.4.

Roadmap

  • Full customization of every default block.
  • Richer clipboard copy/paste (currently plain text).
  • Translations.
  • Uncouple remaining editor parts into individual packages (block components, keyboard service, scroll service…).

License

Novident Editor is a fork of AppFlowy Editor (AppFlowy-IO/appflowy-editor). Upstream is dual-licensed under the GNU Affero General Public License v3 and the Mozilla Public License 2.0.

This fork is used and distributed under the Mozilla Public License 2.0. See LICENSE and NOTICE for full details.

About

Novident Editor is a drop-in rich-text editor for Flutter apps. It renders a document tree built from composable block components

Topics

Resources

Contributing

Stars

2 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages