singerdmx / flutter-quill

Rich text editor for Flutter
https://pub.dev/packages/flutter_quill
MIT License
2.6k stars 839 forks source link

Custom second column for comments #2348

Closed matt3o closed 1 week ago

matt3o commented 2 weeks ago

Is there an existing issue for this?

Use case

I have added inline comments similar to what is described in the docs, see https://github.com/singerdmx/flutter-quill/blob/master/doc/custom_embed_blocks.md including an icon to add a new comment and icon to switch between comment icon and full text comment. Now as another option, I want to display these notes on the right sides in a second column and this time I want to show only the comments, kind of like the comment bar in word. I tried around a little bit but not get it working so far. Any help would be very appreciated!

Full comment including the text: image

Inline comment marker: image

Proposal

A scrollable second widget which only shows the full text comments and not the normal text. Also it should be sychronized with the scrollstate of the main editor (which can probably be achieved by sharing the ScrollController I guess).

matt3o commented 2 weeks ago

In the optimal case I would not have to modify the document itself, but only the visual display but no idea if that is possible. So I far I experimented with editing the document.

CatHood0 commented 2 weeks ago

To make this we will need to add a way to create a connection between a portion of a text, and the custom embed. Then, we will need draw that Embed Objet at the same offset of the that line to make possible something similar... This is not a thing that can be do it on one day.

I'll add it to my todo list.

matt3o commented 2 weeks ago

@CatHood0 Many thanks for the quick response and for taking on the idea :) If I can help you, just write me.

Another but kind of related question: Is there generally any kind of position encoding in the document? I was wondering how difficult it would be to construct a clickable list of contents based on the headings. I know how to extract the headings, but I'm not sure how to locate their position in the document. String matching would work but that sounds super expensive and hacky..

CatHood0 commented 2 weeks ago

Another but kind of related question: Is there generally any kind of position encoding in the document? I was wondering how difficult it would be to construct a clickable list of contents based on the headings. I know how to extract the headings, but I'm not sure how to locate their position in the document. String matching would work but that sounds super expensive and hacky..

Yes, you can, however, you would need to have access to the document nodes. These nodes contain the local offset within themselves (this is because a node can be a Line, or a Block) and a global offset called documentOffset, which as its name indicates, would actually be the position where said node is located in the document. Now, how do we get said nodes?

I think you should try using the following code:

final QuillController _controller = QuillController.basic();
// get the ChildQuery
final  query = _controller.document.queryChild(_controller.selection.baseOffset);
// get the node (can be a Line or a Block)
final Node? node = query.node;
// a way to get the global offset of this node
final int offsetChild = query.offset;
// another way to get the global offset of this node
final int? nodeGlobalOffset = node?.documentOffset;
matt3o commented 2 weeks ago

This is what I went with, it is working for now but I think it's crazy hacky. I am manually iterating through the deltas searching for header elements, then parsing the previous elements for the actual text of the header. Computing can be offloaded via an isolate and I debounced the function as to not call the update too often.

Details

```dart Future>> updateHeadings(UpdateHeadingsParams params) async { final document = params.document; final List> headings = []; final List deltaList = document.toDelta().toList(); String plainTextDocument = document.toPlainText(params.embedBuilders, params.unknownEmbedBuilder); List headerCounters = [0, 0, 0]; // Support for up to three levels of headings int cumulativeLength = 0; for (int i = 0; i < deltaList.length; i++) { final Operation node = deltaList[i]; final int nodeLength = node.length ?? (node.value as String).length; if (node.attributes != null) { for (var entry in node.attributes!.entries) { if (entry.key == 'header') { int headerLevel = entry.value; headerCounters[headerLevel - 1]++; for (int j = headerLevel; j < headerCounters.length; j++) { headerCounters[j] = 0; } String headingText = 'unknown'; int headingStartPosition = cumulativeLength; int lengthBackToBeginningOfHeading = 0; for (int j = i - 1; j >= 0; j--) { final Operation previousNode = deltaList[j]; lengthBackToBeginningOfHeading += (previousNode.length ?? (previousNode.value as String).length); if (previousNode.value is String && (previousNode.attributes == null || !previousNode.attributes!.containsKey('header'))) { String rawText = previousNode.value; List parts = rawText.split(RegExp(r'\r?\n')); headingText = parts.last.trim(); int lenghtOfPartsBeforeHeading = 0; for (int i = 0; i < parts.length - 1; i++) { lenghtOfPartsBeforeHeading += parts[i].length; } lenghtOfPartsBeforeHeading += parts.length - 1; headingStartPosition = cumulativeLength - lengthBackToBeginningOfHeading + lenghtOfPartsBeforeHeading; break; } } String numbering = headerCounters.take(headerLevel).join('.'); int headingEndPosition = headingStartPosition + headingText.length; String displayText = ('-' * (headerLevel - 1)) + ' ' + headingText; headings.add({ 'level': headerLevel, 'text': headingText, 'displayText': displayText, 'node': node, 'startPosition': headingStartPosition, 'endPosition': headingEndPosition, 'length': headingText.length, 'numbering': numbering }); } } } cumulativeLength += nodeLength; } return headings; } ```

CatHood0 commented 2 weeks ago

You can use:

final Root root = _controller.document.root;

You can see more about this class here and about QuillContainer here, Using these classes you can access to all nodes that are Blocks and you can also filter that Blocks by their attributes.

matt3o commented 2 weeks ago

Great tip, that simplifies it a lot:

Code

```dart Future>> updateHeadings(UpdateHeadingsParams params) async { final document = params.document; final Root root = document.root; final List> headings = []; List headerCounters = [0, 0, 0]; // Support for up to three levels of headings print("Root: ${root.childCount} ${root.offset}"); var rootChildren = root.children; for (Node node in rootChildren) { print(node.runtimeType); if (node is Block) { Block block = node; if (block.style.keys.isNotEmpty) print('block style ${block.style.keys}'); } if (node is Line) { if (node.style.attributes.isNotEmpty) print('line style: ${node.style.attributes}'); final heading = node.style.attributes[Attribute.h1.key] ?? node.style.attributes[Attribute.h2.key] ?? node.style.attributes[Attribute.h3.key]; if (heading != null) { headerCounters[heading.value - 1]++; for (int j = heading.value; j < headerCounters.length; j++) { headerCounters[j] = 0; } String numbering = headerCounters.take(heading.value).join('.'); final headingStartPosition = node.offset; final headingEndPosition = node.offset + node.length; headings.add({ 'text': node.toPlainText(), 'level': heading.value, 'offset': node.documentOffset, 'numbering': numbering, 'startPosition': headingStartPosition, 'endPosition': headingEndPosition, }); } } } print('New headings are \n$headings'); return headings; } ```

matt3o commented 2 weeks ago

@CatHood0 Is there any way to get the current quill controller offset without actually clicking somewhere (either first line or from hover?)? I tried to get it from the scrollController but no chance, since I don't know the association between ScrollController position and the actual offset in the document. Together with the code above that would actually be enough to implement what I had in my mind..

CatHood0 commented 2 weeks ago

You can use TextSelection from the QuillController:

final currentSelection = quillController.selection.baseOffset;
matt3o commented 2 weeks ago

@CatHood0 Yes but that triggers / updates only if the user clicked somewhere in the document, or am I using the wrong listener? I want to have a listener which works based on what is visible / where the ScrollController is positioned. Then I could sync the editor position with the comment widget

Listening to updates of the scroll controller:

      print("Scroll offset: ${widget.scrollController.offset}");
      print("Quill Controller position: ${widget.controller.selection.baseOffset}");

Quill Controller position: 1
Scroll offset: 3734
Quill Controller position: 1
Scroll offset: 3834
Quill Controller position: 1
Scroll offset: 3934
Quill Controller position: 1
Scroll offset: 4134

Also the scroll controller offset and the quill controller position are not the same for the same position which makes it even more confusing

CatHood0 commented 1 week ago

What you're trying to do sounds like something very complex. I don't know how to help you with it. Listening to the ScrollController events can be effective, but it's still very difficult.

I would recommend you create your own fork of Flutter Quill and modify it to your liking so that it allows you to get the content you want based on what is displayed on the screen.

The file that contains the QuillEditor itself has several functions that are responsible for finding the corresponding node based on the offset found on the screen.

matt3o commented 1 week ago

@CatHood0 Sad but fair, thanks for all your help then! I'll close this topic

matt3o commented 1 week ago

In case anyone else needs it, here is my sample code for adding comments to flutter quill:

Code

```dart // screens/quill_screen.dart import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'quill_screen_sample_data.dart'; import 'comment_block.dart'; import 'package:flutter_quill/quill_delta.dart'; import 'dart:async'; import 'package:flutter/foundation.dart'; import 'dart:collection' show LinkedList; import 'dart:convert'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: QuillEditorScreen(), ); } } Document filterDocument(Document originalDocument, bool showComments) { // Extract the deltas from the original document. final filteredDelta = Delta(); for (var op in originalDocument.toDelta().toList()) { if (op.isNotPlain) { // Check if the embedded block is a comment if (op.value is Map && op.value.containsKey(NotesBlockEmbed.noteType)) { if (showComments) { // Include the notes embed if we want comments filteredDelta.push(op); } } } else { // Include regular text only if we do NOT want comments if (!showComments) { filteredDelta.push(op); } } } // If filteredDelta is empty, add an empty paragraph to avoid errors if (filteredDelta.isEmpty) { filteredDelta.push(Operation.insert('\n')); } return Document.fromDelta(filteredDelta); } String makeNewlinesVisible(String inputString) { return inputString.replaceAll(RegExp(r'\r?\n'), '⏎'); } class UpdateHeadingsParams { final Document document; final List embedBuilders; final EmbedBuilder unknownEmbedBuilder; UpdateHeadingsParams(this.document, this.embedBuilders, this.unknownEmbedBuilder); } Future>> updateHeadings(UpdateHeadingsParams params) async { final document = params.document; final Root root = document.root; final List> headings = []; List headerCounters = [0, 0, 0]; // Support for up to three levels of headings var rootChildren = root.children; for (Node node in rootChildren) { // print(node.runtimeType); if (node is Block) { Block block = node; // if (block.style.keys.isNotEmpty) print('block style ${block.style.keys}'); } if (node is Line) { // if (node.style.attributes.isNotEmpty) print('line style: ${node.style.attributes}'); final heading = node.style.attributes[Attribute.h1.key] ?? node.style.attributes[Attribute.h2.key] ?? node.style.attributes[Attribute.h3.key]; if (heading != null) { headerCounters[heading.value - 1]++; for (int j = heading.value; j < headerCounters.length; j++) { headerCounters[j] = 0; } String numbering = headerCounters.take(heading.value).join('.'); final headingStartPosition = node.offset; final headingEndPosition = node.offset + node.length; headings.add({ 'text': node.toPlainText(), 'level': heading.value, 'offset': node.documentOffset, 'numbering': numbering, 'startPosition': headingStartPosition, 'endPosition': headingEndPosition, }); } } } print('New headings are \n$headings'); return headings; } Future>> getCommentsInEditor(UpdateHeadingsParams params) async { final document = params.document; final Root root = document.root; final List> comments = []; List headerCounters = [0, 0, 0]; // Support for up to three levels of headings print("Root: ${root.childCount} ${root.offset}"); var rootChildren = root.children; List commentEmbeds = _findAndReturnComments(root.children, embedKey: 'comments'); for (final comment in commentEmbeds) { final commentEmbed = comment.value; // print(comment.value.runtimeType); final decodedJson = jsonDecode(comment.value.data) as Map; final decodedContent = jsonDecode(decodedJson['notes']) as List; // print(decodedJson['notes']); final document = Document.fromJson(decodedContent); comments.add({'plainText': document.toPlainText().replaceAll('\n', ' '), 'document': document, 'offset': comment.documentOffset}); } print('New comments are \n$comments'); return comments; } List _findAndReturnComments(LinkedList children, {String? embedKey}) { List embeds = []; for (final child in children) { // print('child.runtimeType ${child.runtimeType}'); if (child is Line) { embeds.addAll(_findAndReturnComments(child.children)); } else if (child is Block) { embeds.addAll(_findAndReturnComments(child.children)); } else if (child is Embed) { if (embedKey == null) { embeds.add(child); } else { // TODO check the key here! embeds.add(child); } } else if (child is QuillText) { } else { print("Ignoring unknown quill element! ${child.runtimeType}"); } } return embeds; } class QuillEditorWidget extends StatefulWidget { final QuillController controller; final ScrollController scrollController; final Function(List>) onHeadingsUpdate; final Function(List>) onCommentsUpdate; bool showFullComment; QuillEditorWidget({ super.key, required this.controller, required this.scrollController, required this.onHeadingsUpdate, required this.onCommentsUpdate, this.showFullComment = false, }); @override _QuillEditorWidgetState createState() => _QuillEditorWidgetState(); } class _QuillEditorWidgetState extends State { List> _headings = []; List> _comments = []; Timer? _debounce; late List embedBuilders; late EmbedBuilder unknownEmbedBuilder; @override void initState() { super.initState(); embedBuilders = [ NotesEmbedBuilder( addEditNote: _addEditNote, showFullComment: widget.showFullComment, ), ]; unknownEmbedBuilder = UnknownEmbedBuilder(); widget.scrollController.addListener(_scrollListener); widget.controller.addListener(_onDocumentChange); WidgetsBinding.instance?.addPostFrameCallback((_) { _onDocumentChange(); }); } String _getTextAtOffset(double offset) { final doc = widget.controller.document; final text = doc.toPlainText(); final textPainter = TextPainter( text: TextSpan(text: text), textDirection: TextDirection.ltr, ); textPainter.layout(); int position = textPainter.getPositionForOffset(Offset(0, offset)).offset; if (position < text.length) { return text.substring(position, position + 200); } return 'error'; } void _scrollListener() { // Handle the scroll event // print("Scroll position: ${widget.scrollController.position}"); if (widget.scrollController.positions.isNotEmpty) { print("Scroll offset: ${widget.scrollController.offset}"); print("Quill Controller position: ${widget.controller.selection.baseOffset}"); // final query = _controller.document.queryChild(_controller.selection.baseOffset); // print("Text at this position: ${_getTextAtOffset(widget.scrollController.offset)}"); // You can also check the extent of the scroll if (widget.scrollController.position.atEdge) { bool isTop = widget.scrollController.position.pixels == 0; if (isTop) { print("At the top"); } else { print("At the bottom"); } } } } @override void dispose() { widget.scrollController.removeListener(_scrollListener); widget.controller.removeListener(_onDocumentChange); super.dispose(); } void _onDocumentChange() { if (_debounce?.isActive ?? false) _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () async { final UpdateHeadingsParams params = UpdateHeadingsParams( widget.controller.document, embedBuilders, unknownEmbedBuilder, ); final headings = await compute(updateHeadings, params); final comments = await compute(getCommentsInEditor, params); setState(() { _headings = headings; _comments = comments; }); widget.onHeadingsUpdate(_headings); widget.onCommentsUpdate(_comments); }); } @override Widget build(BuildContext context) { return Column( children: [ QuillSimpleToolbar( controller: widget.controller, configurations: QuillSimpleToolbarConfigurations(customButtons: [ QuillToolbarCustomButtonOptions( icon: Icon(Icons.add_comment), onPressed: () { debugPrint('add new comment'); _addEditNote(context); }, tooltip: 'Add a new comment', ), QuillToolbarCustomButtonOptions( icon: Icon(widget.showFullComment ? Icons.toggle_on : Icons.toggle_off), onPressed: () { setState(() { widget.showFullComment = !widget.showFullComment; embedBuilders = [ NotesEmbedBuilder( addEditNote: _addEditNote, showFullComment: widget.showFullComment, ), ]; }); }, tooltip: 'Toggle comment mode', ), ]), ), Expanded( child: QuillEditor.basic( controller: widget.controller, focusNode: FocusNode(), scrollController: widget.scrollController, configurations: QuillEditorConfigurations( autoFocus: true, // expands: true, padding: EdgeInsets.all(8), paintCursorAboveText: true, enableInteractiveSelection: true, // enableSelectionToolbar: false, // scrollable: true, // detectWordBoundary: false, embedBuilders: embedBuilders, unknownEmbedBuilder: unknownEmbedBuilder, ), ), ), ], ); } Future _addEditNote(BuildContext context, {Document? document}) async { final isEditing = document != null; final quillEditorController = QuillController( document: document ?? Document(), selection: const TextSelection.collapsed(offset: 0), ); await showDialog( context: context, builder: (context) => AlertDialog( titlePadding: const EdgeInsets.only(left: 16, top: 8), title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('${isEditing ? 'Edit' : 'Add'} note'), IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ) ], ), content: QuillEditor.basic( controller: quillEditorController, configurations: const QuillEditorConfigurations(), ), ), ); if (quillEditorController.document.isEmpty()) return; final block = BlockEmbed.custom( NotesBlockEmbed.fromDocument(quillEditorController.document), ); final controller = widget.controller; final index = controller.selection.baseOffset; final length = controller.selection.extentOffset - index; if (isEditing) { final offset = getEmbedNode(controller, controller.selection.start).offset; controller.replaceText(offset, 1, block, TextSelection.collapsed(offset: offset)); } else { controller.replaceText(index, length, block, null); } } } class QuillEditorScreen extends StatefulWidget { @override _QuillEditorScreenState createState() => _QuillEditorScreenState(); } class _QuillEditorScreenState extends State { late QuillController _controller; final ScrollController _scrollController = ScrollController(); bool showFullComment = false; List> _headings = []; @override void initState() { super.initState(); // final mdDocument = md.Document( // encodeHtml: false, // extensionSet: md.ExtensionSet.gitHubFlavored, // ); // final mdToDelta = MarkdownToDelta( // markdownDocument: mdDocument, // ); // final delta = mdToDelta.convert(markdownSampleText); // final htmlDelta = HtmlToDelta().convert(leasingContractHtml); final delta = Delta()..insert(aliceInWonderland + '\n'); var document = Document.fromDelta(delta); _controller = QuillController( document: document, selection: TextSelection.collapsed(offset: 0), ); } void _onHeadingsUpdate(List> headings) { setState(() { _headings = headings; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Quill Editor'), ), body: Padding( padding: const EdgeInsets.all(25.0), child: QuillEditorWidget( controller: _controller, scrollController: _scrollController, onHeadingsUpdate: (headings) { setState(() { _headings = headings; }); }, onCommentsUpdate: (comments) {}, ), ), ); } } class UnknownEmbedBuilder implements EmbedBuilder { @override String get key => 'unknown'; @override Widget build(BuildContext context, QuillController controller, Embed node, bool readOnly, bool isSelected, TextStyle textStyle) { return Container( color: Colors.red, child: Text('Unsupported embed type', style: TextStyle(color: Colors.white)), ); } @override WidgetSpan buildWidgetSpan(Widget widget) { return WidgetSpan(child: widget); } @override String toPlainText(Embed node) { return ''; } @override bool get expanded => false; } ``` ```dart // comment_block.dart import 'package:flutter_quill/flutter_quill.dart'; import 'dart:convert'; import 'package:flutter/material.dart'; class NotesBlockEmbed extends CustomBlockEmbed { const NotesBlockEmbed(String value) : super(noteType, value); static const String noteType = 'notes'; static NotesBlockEmbed fromDocument(Document document) => NotesBlockEmbed(jsonEncode(document.toDelta().toJson())); Document get document => Document.fromJson(jsonDecode(data)); } class NotesEmbedBuilder extends EmbedBuilder { NotesEmbedBuilder({required this.addEditNote, this.showFullComment}); final Future Function(BuildContext context, {Document? document}) addEditNote; final bool? showFullComment; @override String get key => 'notes'; @override Widget build( BuildContext context, QuillController controller, Embed node, bool readOnly, bool inline, TextStyle textStyle, ) { final notes = NotesBlockEmbed(node.value.data).document; final fullCommentText = notes.toPlainText().replaceAll('\n', ' '); Widget buildCommentSymbol() { return IntrinsicWidth( child: InkWell( onTap: () => addEditNote(context, document: notes), child: Container( padding: EdgeInsets.all(5), margin: EdgeInsets.only(left: 5, right: 5), decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(5), border: Border.all(color: Colors.grey), ), child: const Icon(Icons.comment, size: 16), ), )); } Widget buildFullComment() { return InkWell( onTap: () => addEditNote(context, document: notes), child: Row(children: [ Expanded( child: Container( padding: EdgeInsets.all(10), margin: EdgeInsets.only(top: 10), decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.grey), ), child: Text( fullCommentText, style: textStyle, ), )) ])); } final widget; switch (showFullComment) { case false: widget = buildCommentSymbol(); case true: widget = buildFullComment(); case null: widget = buildFullComment(); } return widget; } } ```