live_markdown_editor 0.5.3
live_markdown_editor: ^0.5.3 copied to clipboard
A source-preserving Flutter Markdown editor with raw, live, and read modes.
example/lib/main.dart
import 'package:flutter/widgets.dart';
import 'package:live_markdown_editor/live_markdown_editor.dart';
import 'cupertino_tab.dart';
import 'demo_palette.dart';
import 'demo_presets.dart';
import 'demo_settings.dart';
import 'demo_widgets.dart';
import 'foundation_tab.dart';
import 'material_tab.dart';
void main() => runApp(const EditorDemoApp());
/// The manuscript casebook: a masthead, a live stats ticker, a catalog rail
/// of curated documents, and the editor hosted in a Foundation (neutral),
/// Material, or Cupertino shell — all editing one shared controller.
final class EditorDemoApp extends StatefulWidget {
const EditorDemoApp({super.key});
@override
State<EditorDemoApp> createState() => _EditorDemoAppState();
}
final class _EditorDemoAppState extends State<EditorDemoApp> {
late final MarkdownEditorController _controller = MarkdownEditorController(
text: demoPresets.first.markdown,
);
DemoSettings _settings = const DemoSettings();
var _host = 0;
var _plate = 0;
var _mode = MarkdownEditorMode.live;
var _words = 0;
var _chars = 0;
var _lines = 1;
String _action = '';
@override
void initState() {
super.initState();
_controller.addListener(_refreshStats);
_refreshStats();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _refreshStats() {
final text = _controller.text;
final words = text.trim().isEmpty
? 0
: text.trim().split(RegExp(r'\s+')).length;
final lines = '\n'.allMatches(text).length + 1;
setState(() {
_words = words;
_chars = text.length;
_lines = lines;
});
}
void _loadPlate(int index) {
setState(() {
_plate = index;
_controller.text = demoPresets[index].markdown;
_controller.mode = _mode;
_action = '';
});
}
void _setMode(MarkdownEditorMode mode) {
setState(() => _mode = mode);
_controller.mode = mode;
}
void _onLink(String url) => setState(() => _action = 'Link · $url');
@override
Widget build(BuildContext context) {
final palette = _settings.dark ? Palette.dark : Palette.light;
final preset = demoPresets[_plate];
final footnote = _action.isEmpty
? 'Right-click or long-press opens the platform\'s native menu — on web the browser owns it.'
: '$_action — right-click or long-press opens the native menu.';
return Directionality(
textDirection: TextDirection.ltr,
child: AnimatedContainer(
duration: const Duration(milliseconds: 260),
color: palette.paper,
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Masthead(
palette: palette,
mode: _mode,
host: _host,
dark: _settings.dark,
onMode: _setMode,
onHost: (h) => setState(() => _host = h),
onDark: (v) =>
setState(() => _settings = _settings.copyWith(dark: v)),
),
if (MediaQuery.sizeOf(context).height >= 640)
_Ticker(
palette: palette,
words: _words,
chars: _chars,
lines: _lines,
mode: _mode,
host: _host,
direction: _settings.direction,
menu: 'native · browser on web',
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 760) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_RailStrip(
palette: palette,
selected: _plate,
onSelect: _loadPlate,
),
Container(height: 1, color: palette.hairline),
Expanded(child: _hostPage(palette, preset, footnote)),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Rail(
palette: palette,
selected: _plate,
onSelect: _loadPlate,
),
Container(width: 1, color: palette.hairline),
Expanded(child: _hostPage(palette, preset, footnote)),
],
);
},
),
),
],
),
),
),
);
}
Widget _hostPage(Palette palette, DemoPreset preset, String footnote) {
void onSettingsChanged(DemoSettings s) => setState(() => _settings = s);
final shared = (
controller: _controller,
settings: _settings,
palette: palette,
preset: preset,
footnote: footnote,
onSettingsChanged: onSettingsChanged,
onAction: _onLink,
);
return switch (_host) {
1 => MaterialTab(
controller: shared.controller,
settings: shared.settings,
palette: shared.palette,
preset: shared.preset,
footnote: shared.footnote,
onSettingsChanged: shared.onSettingsChanged,
onAction: shared.onAction,
),
2 => CupertinoTab(
controller: shared.controller,
settings: shared.settings,
palette: shared.palette,
preset: shared.preset,
footnote: shared.footnote,
onSettingsChanged: shared.onSettingsChanged,
onAction: shared.onAction,
),
_ => FoundationTab(
controller: shared.controller,
settings: shared.settings,
palette: shared.palette,
preset: shared.preset,
footnote: shared.footnote,
onSettingsChanged: shared.onSettingsChanged,
onAction: shared.onAction,
),
};
}
}
/// Masthead: kicker, bilingual display title, and the mode/host/dark controls.
class _Masthead extends StatelessWidget {
const _Masthead({
required this.palette,
required this.mode,
required this.host,
required this.dark,
required this.onMode,
required this.onHost,
required this.onDark,
});
final Palette palette;
final MarkdownEditorMode mode;
final int host;
final bool dark;
final ValueChanged<MarkdownEditorMode> onMode;
final ValueChanged<int> onHost;
final ValueChanged<bool> onDark;
@override
Widget build(BuildContext context) {
// The masthead compacts on short viewports so chrome never dwarfs the
// editor: two tiers below 640px/520px of window height.
final height = MediaQuery.sizeOf(context).height;
final compact = height < 640;
final tiny = height < 520;
final horizontal = tiny
? 20.0
: compact
? 24.0
: 28.0;
return Container(
padding: EdgeInsets.fromLTRB(
horizontal,
tiny
? 10
: compact
? 12
: 18,
horizontal,
tiny
? 10
: compact
? 12
: 16,
),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: palette.hairline)),
),
child: Stagger(
children: [
Row(
children: [
Text('LIVE MARKDOWN EDITOR', style: smallCaps(palette)),
const Spacer(),
Text('v0.5.3', style: smallCaps(palette)),
],
),
SizedBox(
height: tiny
? 4
: compact
? 6
: 10,
),
Text(
'The Manuscript',
style: TextStyle(
color: palette.ink,
fontFamily: kLatinDisplay,
fontSize: tiny
? 26
: compact
? 32
: 42,
height: 1,
fontVariations: const [FontVariation('wght', 700)],
),
),
SizedBox(height: tiny ? 3 : 6),
Text('Casebook — three hosts, one source', style: smallCaps(palette)),
SizedBox(
height: tiny
? 8
: compact
? 10
: 16,
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
EditorialSegmented<MarkdownEditorMode>(
value: mode,
palette: palette,
onChanged: onMode,
options: const [
(MarkdownEditorMode.raw, 'RAW'),
(MarkdownEditorMode.live, 'LIVE'),
(MarkdownEditorMode.read, 'READ'),
],
),
const SizedBox(width: 18),
EditorialSegmented<int>(
value: host,
palette: palette,
onChanged: onHost,
options: const [
(0, 'FOUNDATION'),
(1, 'MATERIAL'),
(2, 'CUPERTINO'),
],
),
const SizedBox(width: 18),
EditorialSwitch(
value: dark,
palette: palette,
label: 'NIGHT',
onChanged: onDark,
),
],
),
),
],
),
);
}
}
/// The live ticker: document stats and current state, in tabular small caps.
class _Ticker extends StatelessWidget {
const _Ticker({
required this.palette,
required this.words,
required this.chars,
required this.lines,
required this.mode,
required this.host,
required this.direction,
required this.menu,
});
final Palette palette;
final int words;
final int chars;
final int lines;
final MarkdownEditorMode mode;
final int host;
final MarkdownDirectionMode direction;
final String menu;
static const _hostNames = ['foundation', 'material', 'cupertino'];
@override
Widget build(BuildContext context) {
Widget cell(String value, String label) => Text.rich(
TextSpan(
children: [
TextSpan(
text: value,
style: smallCaps(palette, color: palette.accent),
),
TextSpan(text: ' $label', style: smallCaps(palette)),
],
),
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 8),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: palette.hairline)),
),
child: Wrap(
spacing: 18,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
cell('$words', 'WORD'),
cell('$chars', 'CHAR'),
cell('$lines', 'LINE'),
cell(mode.name.toUpperCase(), 'MODE'),
cell(_hostNames[host].toUpperCase(), 'HOST'),
cell(direction.name.toUpperCase(), 'DIR'),
cell(menu, 'MENU'),
],
),
);
}
}
/// Narrow-layout rail: the same plates as a horizontal strip.
class _RailStrip extends StatelessWidget {
const _RailStrip({
required this.palette,
required this.selected,
required this.onSelect,
});
final Palette palette;
final int selected;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
return Container(
height: 62,
color: palette.paper,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text('Casebook', style: smallCaps(palette)),
),
for (var i = 0; i < demoPresets.length; i++)
_StripTile(
preset: demoPresets[i],
palette: palette,
selected: i == selected,
onTap: () => onSelect(i),
),
],
),
),
);
}
}
class _StripTile extends StatelessWidget {
const _StripTile({
required this.preset,
required this.palette,
required this.selected,
required this.onTap,
});
final DemoPreset preset;
final Palette palette;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: selected ? palette.card : const Color(0x00000000),
border: Border.all(
color: selected ? palette.accent : palette.hairline,
),
),
child: Row(
children: [
Text(
preset.number.toString().padLeft(2, '0'),
style: TextStyle(
color: selected ? palette.accent : palette.muted,
fontFamily: kLatinDisplay,
fontSize: 15,
height: 1,
fontVariations: const [FontVariation('wght', 700)],
),
),
const SizedBox(width: 8),
Text(
preset.title,
style: TextStyle(
color: selected ? palette.ink : palette.muted,
fontFamily: kLatinBody,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
/// The catalog rail: the casebook plates, one active at a time.
class _Rail extends StatelessWidget {
const _Rail({
required this.palette,
required this.selected,
required this.onSelect,
});
final Palette palette;
final int selected;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
return Container(
width: 236,
color: palette.paper,
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 18, 24, 10),
child: Text('Casebook', style: smallCaps(palette)),
),
for (var i = 0; i < demoPresets.length; i++)
_PlateTile(
preset: demoPresets[i],
palette: palette,
selected: i == selected,
onTap: () => onSelect(i),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 14, 24, 0),
child: Text(
'Every plate loads into the same editor',
style: TextStyle(
color: palette.muted,
fontFamily: kLatinBody,
fontSize: 11.5,
height: 1.4,
),
),
),
],
),
);
}
}
class _PlateTile extends StatelessWidget {
const _PlateTile({
required this.preset,
required this.palette,
required this.selected,
required this.onTap,
});
final DemoPreset preset;
final Palette palette;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: selected ? palette.card : const Color(0x00000000),
border: Border(
left: BorderSide(
width: 3,
color: selected ? palette.accent : palette.paper,
),
),
),
child: Row(
children: [
Text(
preset.number.toString().padLeft(2, '0'),
style: TextStyle(
color: selected ? palette.accent : palette.muted,
fontFamily: kLatinDisplay,
fontSize: 18,
height: 1,
fontVariations: const [FontVariation('wght', 700)],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
preset.title,
style: TextStyle(
color: selected ? palette.ink : palette.muted,
fontFamily: kLatinBody,
fontSize: 14,
height: 1.25,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
preset.subtitle,
style: TextStyle(
color: selected ? palette.accent : palette.muted,
fontFamily: kArabicBody,
fontSize: 11.5,
height: 1.3,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
);
}
}