Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 364 additions & 0 deletions packages/fleather/example/lib/autoformat_iterator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
import 'dart:convert';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better to have an example app showcasing every feature we have instead of a separate example for each feature, and a simple example to get users started quickly. I suggest to remove this example from this PR and create another PR for a complete advanced example. WDYT?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Amir-P,

This is really the main example with some additions to showcase this extra feature so it could overwrite the example app you have there already if you choose to use it. I have been taking notes with the intention to eventually help with some of the documentation and covering several use cases with Fleather and Parchment. Trying to build a few cohesive examples. This and #371 both have examples working with the same "youtube" custom block example.

However, I'm not married to any of it! Definitely take what you like and leave the rest. :)

Copy link
Member

@Amir-P Amir-P Jun 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case let's rename this into something like example_advanced.dart, then we can simplify current example we have in another PR by removing inline images and embeds for users to get started with basics quicker. Also I can see you've defined YoutubeEmbed but never used it. @cotw-fabier

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me. I also realized I left the old version of the autoformats.buildWithFallback command in the example. Let me clean it up and post a new commit. Should be able to do it closer to the weekend -- need to wrap up a work project tomorrow.

import 'dart:io';

import 'package:fleather/fleather.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:parchment_delta/parchment_delta.dart';
import 'package:url_launcher/url_launcher.dart';

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

class FleatherApp extends StatelessWidget {
const FleatherApp({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
title: 'Fleather - rich-text editor for Flutter',
home: HomePage(),
);
}

class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);

@override
_HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
final FocusNode _focusNode = FocusNode();
FleatherController? _controller;

@override
void initState() {
super.initState();
if (kIsWeb) BrowserContextMenu.disableContextMenu();
_initController();
}

@override
void dispose() {
super.dispose();
if (kIsWeb) BrowserContextMenu.enableContextMenu();
}

Future<void> _initController() async {
try {
final result = await rootBundle.loadString('assets/welcome.json');

/// Build Autoformats with backups
/// Autoformats allow for ergonomic automatic text transformations.
/// This example takes ![youtube link] and transforms it into a youtube blockembed.
/// Fallback text transformations apply styles by using markdown such as _italics_ or **bold**.
final customAutoFormat = AutoFormatYoutubeEmbed();
final autoFormats = AutoFormats.buildWithFallback([customAutoFormat]);

/// Heuristics work very similar to autoformats but are focused on improving the editing experience.
final heuristics = ParchmentHeuristics(
formatRules: [],
insertRules: [
ForceNewlineForInsertsAroundInlineImageRule(),
],
deleteRules: [],
).merge(ParchmentHeuristics.fallback);
final doc = ParchmentDocument.fromJson(
jsonDecode(result),
heuristics: heuristics,
);
_controller = FleatherController(document: doc, autoFormats: autoFormats);
} catch (err, st) {
print('Cannot read welcome.json: $err\n$st');
_controller = FleatherController();
}
setState(() {});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(elevation: 0, title: Text('Fleather Demo')),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final picker = ImagePicker();
final image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
final selection = _controller!.selection;
_controller!.replaceText(
selection.baseOffset,
selection.extentOffset - selection.baseOffset,
EmbeddableObject('image', inline: false, data: {
'source_type': kIsWeb ? 'url' : 'file',
'source': image.path,
}),
);
_controller!.replaceText(
selection.baseOffset + 1,
0,
'\n',
selection:
TextSelection.collapsed(offset: selection.baseOffset + 2),
);
}
},
child: Icon(Icons.add_a_photo),
),
body: _controller == null
? Center(child: const CircularProgressIndicator())
: Column(
children: [
FleatherToolbar.basic(controller: _controller!),
Divider(height: 1, thickness: 1, color: Colors.grey.shade200),
Expanded(
child: FleatherEditor(
controller: _controller!,
focusNode: _focusNode,
padding: EdgeInsets.only(
left: 16,
right: 16,
bottom: MediaQuery.of(context).padding.bottom,
),
onLaunchUrl: _launchUrl,
maxContentWidth: 800,
embedBuilder: _embedBuilder,
spellCheckConfiguration: SpellCheckConfiguration(
spellCheckService: DefaultSpellCheckService(),
misspelledSelectionColor: Colors.red,
misspelledTextStyle:
DefaultTextStyle.of(context).style),
),
),
],
),
);
}

Widget _embedBuilder(BuildContext context, EmbedNode node) {
if (node.value.type == 'icon') {
final data = node.value.data;
// Icons.rocket_launch_outlined
return Icon(
IconData(int.parse(data['codePoint']), fontFamily: data['fontFamily']),
color: Color(int.parse(data['color'])),
size: 18,
);
}

if (node.value.type == 'youtube') {
final data = node.value.data;
final url = data['url'];
final thumbUrl = data['thumbUrl'];
final subtitles = data['subtitles'];
final language = data['language'];

return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
children: [
if (thumbUrl != null)
Image.network(thumbUrl,
width: 300, height: 169, fit: BoxFit.cover),
Text(
'Language: $language',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
Text(
'Subtitles: $subtitles',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
TextButton(
onPressed: () => {}, // _launchUrl(url),
child: Text('Watch on YouTube'),
),
],
),
);
}

if (node.value.type == 'image') {
final sourceType = node.value.data['source_type'];
ImageProvider? image;
if (sourceType == 'assets') {
image = AssetImage(node.value.data['source']);
} else if (sourceType == 'file') {
image = FileImage(File(node.value.data['source']));
} else if (sourceType == 'url') {
image = NetworkImage(node.value.data['source']);
}
if (image != null) {
return Padding(
// Caret takes 2 pixels, hence not symmetric padding values.
padding: const EdgeInsets.only(left: 4, right: 2, top: 2, bottom: 2),
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
image: DecorationImage(image: image, fit: BoxFit.cover),
),
),
);
}
}

return defaultFleatherEmbedBuilder(context, node);
}

void _launchUrl(String? url) async {
if (url == null) return;
final uri = Uri.parse(url);
final _canLaunch = await canLaunchUrl(uri);
if (_canLaunch) {
await launchUrl(uri);
}
}
}

/// This is an example insert rule that will insert a new line before and
/// after inline image embed.
class ForceNewlineForInsertsAroundInlineImageRule extends InsertRule {
@override
Delta? apply(Delta document, int index, Object data) {
if (data is! String) return null;

final iter = DeltaIterator(document);
final previous = iter.skip(index);
final target = iter.next();
final cursorBeforeInlineEmbed = _isInlineImage(target.data);
final cursorAfterInlineEmbed =
previous != null && _isInlineImage(previous.data);

if (cursorBeforeInlineEmbed || cursorAfterInlineEmbed) {
final delta = Delta()..retain(index);
if (cursorAfterInlineEmbed && !data.startsWith('\n')) {
delta.insert('\n');
}
delta.insert(data);
if (cursorBeforeInlineEmbed && !data.endsWith('\n')) {
delta.insert('\n');
}
return delta;
}
return null;
}

bool _isInlineImage(Object data) {
if (data is EmbeddableObject) {
return data.type == 'image' && data.inline;
}
if (data is Map) {
return data[EmbeddableObject.kTypeKey] == 'image' &&
data[EmbeddableObject.kInlineKey];
}
return false;
}
}

/// Define a custom autoformat for styling a custom embed object.
/// Use it by typing `!https://www.youtube.com/watch?v=dQw4w9WgXcQ` and then a space.
class AutoFormatYoutubeEmbed extends AutoFormat {
static final _youtubePattern =
RegExp(r'!https:\/\/www\.youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)$');

const AutoFormatYoutubeEmbed();

@override
AutoFormatResult? apply(
ParchmentDocument document, int position, String data) {
// This rule applies to a space inserted after a YouTube URL, so we can ignore everything else.
if (data != ' ') return null;

final documentDelta = document.toDelta();
final iter = DeltaIterator(documentDelta);
final previous = iter.skip(position);
// No previous operation means nothing to analyze.
if (previous == null || previous.data is! String) return null;
final previousText = previous.data as String;

// Split text of previous operation in lines and words and take the last word to test.
final candidate = previousText.split('\n').last.split(' ').last;
final match = _youtubePattern.firstMatch(candidate);
if (match == null) return null;

final videoId = match.group(1);
final url = 'https://www.youtube.com/watch?v=$videoId';
final thumbUrl = 'https://img.youtube.com/vi/$videoId/0.jpg';

final youtubeEmbedDelta = {
'_type': 'youtube',
'_inline': false,
'url': url,
'subtitles': 'English',
'language': 'en',
'thumbUrl': thumbUrl
};

final change = Delta()
..retain(position - candidate.length)
..delete(candidate.length + 1)
..insert('\n')
..insert(youtubeEmbedDelta)
..insert('\n');

final undo = change.invert(documentDelta);
document.compose(change, ChangeSource.local);

return AutoFormatResult(
change: change,
undo: undo,
undoPositionCandidate: position - candidate.length + 1,
selection:
TextSelection.collapsed(offset: position - candidate.length + 2),
undoSelection: TextSelection.collapsed(offset: position),
);
}
}

/// This class formats our custom youtube embed. This is a very simply implementation.
/// But you should see how we can take this amazing places.

abstract class Embed {
Widget build(BuildContext context, Map<String, dynamic> data);
String get type;
}

class YoutubeEmbed implements Embed {
@override
Widget build(BuildContext context, Map<String, dynamic> data) {
final url = data['url'];
final thumbUrl = data['thumbUrl'];
final subtitles = data['subtitles'];
final language = data['language'];

return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
children: [
if (thumbUrl != null)
Image.network(thumbUrl, width: 300, height: 169, fit: BoxFit.cover),
Text(
'Language: $language',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
Text(
'Subtitles: $subtitles',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
TextButton(
onPressed: () => {}, // _launchUrl(url),
child: Text('Watch on YouTube'),
),
],
),
);
}

@override
String get type => 'youtube';
}
Loading