Merge branch 'master' into custom_rules

pull/205/head
Xun Gong 4 years ago
commit ee11aef543
  1. 2
      .github/ISSUE_TEMPLATE/issue-template.md
  2. 6
      CHANGELOG.md
  3. 35
      lib/models/documents/attribute.dart
  4. 5
      lib/models/documents/document.dart
  5. 22
      lib/models/documents/nodes/line.dart
  6. 18
      lib/models/documents/style.dart
  7. 2
      lib/models/quill_delta.dart
  8. 45
      lib/models/rules/insert.dart
  9. 35
      lib/widgets/controller.dart
  10. 63
      lib/widgets/editor.dart
  11. 27
      lib/widgets/raw_editor.dart
  12. 344
      lib/widgets/simple_viewer.dart
  13. 64
      lib/widgets/text_block.dart
  14. 49
      lib/widgets/toolbar.dart
  15. 2
      pubspec.yaml

@ -13,4 +13,4 @@ My issue is about [Desktop]
I have tried running `example` directory successfully before creating an issue here. I have tried running `example` directory successfully before creating an issue here.
Please note that we are using stable channel. If you are using beta or master channel, those are not supported. Please note that we are using stable channel on branch master. If you are using beta or master channel, use branch dev.

@ -1,3 +1,9 @@
## [1.2.2]
* Checkbox supports tapping.
## [1.2.1]
* Indented position not holding while editing.
## [1.2.0] ## [1.2.0]
* Fix image button cancel causes crash. * Fix image button cancel causes crash.

@ -1,3 +1,5 @@
import 'dart:collection';
import 'package:quiver/core.dart'; import 'package:quiver/core.dart';
enum AttributeScope { enum AttributeScope {
@ -14,7 +16,7 @@ class Attribute<T> {
final AttributeScope scope; final AttributeScope scope;
final T value; final T value;
static final Map<String, Attribute> _registry = { static final Map<String, Attribute> _registry = LinkedHashMap.of({
Attribute.bold.key: Attribute.bold, Attribute.bold.key: Attribute.bold,
Attribute.italic.key: Attribute.italic, Attribute.italic.key: Attribute.italic,
Attribute.underline.key: Attribute.underline, Attribute.underline.key: Attribute.underline,
@ -26,16 +28,16 @@ class Attribute<T> {
Attribute.background.key: Attribute.background, Attribute.background.key: Attribute.background,
Attribute.placeholder.key: Attribute.placeholder, Attribute.placeholder.key: Attribute.placeholder,
Attribute.header.key: Attribute.header, Attribute.header.key: Attribute.header,
Attribute.indent.key: Attribute.indent,
Attribute.align.key: Attribute.align, Attribute.align.key: Attribute.align,
Attribute.list.key: Attribute.list, Attribute.list.key: Attribute.list,
Attribute.codeBlock.key: Attribute.codeBlock, Attribute.codeBlock.key: Attribute.codeBlock,
Attribute.blockQuote.key: Attribute.blockQuote, Attribute.blockQuote.key: Attribute.blockQuote,
Attribute.indent.key: Attribute.indent,
Attribute.width.key: Attribute.width, Attribute.width.key: Attribute.width,
Attribute.height.key: Attribute.height, Attribute.height.key: Attribute.height,
Attribute.style.key: Attribute.style, Attribute.style.key: Attribute.style,
Attribute.token.key: Attribute.token, Attribute.token.key: Attribute.token,
}; });
static final BoldAttribute bold = BoldAttribute(); static final BoldAttribute bold = BoldAttribute();
@ -88,22 +90,22 @@ class Attribute<T> {
Attribute.placeholder.key, Attribute.placeholder.key,
}; };
static final Set<String> blockKeys = { static final Set<String> blockKeys = LinkedHashSet.of({
Attribute.header.key, Attribute.header.key,
Attribute.indent.key,
Attribute.align.key, Attribute.align.key,
Attribute.list.key, Attribute.list.key,
Attribute.codeBlock.key, Attribute.codeBlock.key,
Attribute.blockQuote.key, Attribute.blockQuote.key,
}; Attribute.indent.key,
});
static final Set<String> blockKeysExceptHeader = { static final Set<String> blockKeysExceptHeader = LinkedHashSet.of({
Attribute.list.key, Attribute.list.key,
Attribute.indent.key,
Attribute.align.key, Attribute.align.key,
Attribute.codeBlock.key, Attribute.codeBlock.key,
Attribute.blockQuote.key, Attribute.blockQuote.key,
}; Attribute.indent.key,
});
static Attribute<int?> get h1 => HeaderAttribute(level: 1); static Attribute<int?> get h1 => HeaderAttribute(level: 1);
@ -151,8 +153,11 @@ class Attribute<T> {
if (level == 2) { if (level == 2) {
return indentL2; return indentL2;
} }
if (level == 3) {
return indentL3; return indentL3;
} }
return IndentAttribute(level: level);
}
bool get isInline => scope == AttributeScope.INLINE; bool get isInline => scope == AttributeScope.INLINE;
@ -169,6 +174,18 @@ class Attribute<T> {
return attribute; return attribute;
} }
static int getRegistryOrder(Attribute attribute) {
var order = 0;
for (final attr in _registry.values) {
if (attr.key == attribute.key) {
break;
}
order++;
}
return order;
}
static Attribute clone(Attribute origin, dynamic value) { static Attribute clone(Attribute origin, dynamic value) {
return Attribute(origin.key, origin.scope, value); return Attribute(origin.key, origin.scope, value);
} }

@ -234,7 +234,12 @@ class Document {
String toPlainText() => _root.children.map((e) => e.toPlainText()).join(); String toPlainText() => _root.children.map((e) => e.toPlainText()).join();
void _loadDocument(Delta doc) { void _loadDocument(Delta doc) {
if (doc.isEmpty) {
throw ArgumentError.value(doc, 'Document Delta cannot be empty.');
}
assert((doc.last.data as String).endsWith('\n')); assert((doc.last.data as String).endsWith('\n'));
var offset = 0; var offset = 0;
for (final op in doc.toList()) { for (final op in doc.toList()) {
if (!op.isInsert) { if (!op.isInsert) {

@ -1,5 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:collection/collection.dart';
import '../../quill_delta.dart'; import '../../quill_delta.dart';
import '../attribute.dart'; import '../attribute.dart';
import '../style.dart'; import '../style.dart';
@ -203,22 +205,28 @@ class Line extends Container<Leaf?> {
} // No block-level changes } // No block-level changes
if (parent is Block) { if (parent is Block) {
final parentStyle = (parent as Block).style.getBlockExceptHeader(); final parentStyle = (parent as Block).style.getBlocksExceptHeader();
if (blockStyle.value == null) { if (blockStyle.value == null) {
_unwrap(); _unwrap();
} else if (blockStyle != parentStyle) { } else if (!const MapEquality()
.equals(newStyle.getBlocksExceptHeader(), parentStyle)) {
_unwrap(); _unwrap();
final block = Block()..applyAttribute(blockStyle); _applyBlockStyles(newStyle);
_wrap(block);
block.adjust();
} // else the same style, no-op. } // else the same style, no-op.
} else if (blockStyle.value != null) { } else if (blockStyle.value != null) {
// Only wrap with a new block if this is not an unset // Only wrap with a new block if this is not an unset
final block = Block()..applyAttribute(blockStyle); _applyBlockStyles(newStyle);
}
}
void _applyBlockStyles(Style newStyle) {
var block = Block();
for (final style in newStyle.getBlocksExceptHeader().values) {
block = block..applyAttribute(style);
}
_wrap(block); _wrap(block);
block.adjust(); block.adjust();
} }
}
/// Wraps this line with new parent [block]. /// Wraps this line with new parent [block].
/// ///

@ -30,7 +30,8 @@ class Style {
Iterable<String> get keys => _attributes.keys; Iterable<String> get keys => _attributes.keys;
Iterable<Attribute> get values => _attributes.values; Iterable<Attribute> get values => _attributes.values.sorted(
(a, b) => Attribute.getRegistryOrder(a) - Attribute.getRegistryOrder(b));
Map<String, Attribute> get attributes => _attributes; Map<String, Attribute> get attributes => _attributes;
@ -48,6 +49,11 @@ class Style {
bool containsKey(String key) => _attributes.containsKey(key); bool containsKey(String key) => _attributes.containsKey(key);
Attribute? getBlockExceptHeader() { Attribute? getBlockExceptHeader() {
for (final val in values) {
if (val.isBlockExceptHeader && val.value != null) {
return val;
}
}
for (final val in values) { for (final val in values) {
if (val.isBlockExceptHeader) { if (val.isBlockExceptHeader) {
return val; return val;
@ -56,6 +62,16 @@ class Style {
return null; return null;
} }
Map<String, Attribute> getBlocksExceptHeader() {
final m = <String, Attribute>{};
attributes.forEach((key, value) {
if (Attribute.blockKeysExceptHeader.contains(key)) {
m[key] = value;
}
});
return m;
}
Style merge(Attribute attribute) { Style merge(Attribute attribute) {
final merged = Map<String, Attribute>.from(_attributes); final merged = Map<String, Attribute>.from(_attributes);
if (attribute.value == null) { if (attribute.value == null) {

@ -265,7 +265,7 @@ class Delta {
List<Operation> toList() => List.from(_operations); List<Operation> toList() => List.from(_operations);
/// Returns JSON-serializable version of this delta. /// Returns JSON-serializable version of this delta.
List toJson() => toList(); List toJson() => toList().map((operation) => operation.toJson()).toList();
/// Returns `true` if this delta is empty. /// Returns `true` if this delta is empty.
bool get isEmpty => _operations.isEmpty; bool get isEmpty => _operations.isEmpty;

@ -55,6 +55,14 @@ class PreserveLineStyleOnSplitRule extends InsertRule {
} }
} }
/// Preserves block style when user inserts text containing newlines.
///
/// This rule handles:
///
/// * inserting a new line in a block
/// * pasting text containing multiple lines of text in a block
///
/// This rule may also be activated for changes triggered by auto-correct.
class PreserveBlockStyleOnInsertRule extends InsertRule { class PreserveBlockStyleOnInsertRule extends InsertRule {
const PreserveBlockStyleOnInsertRule(); const PreserveBlockStyleOnInsertRule();
@ -62,28 +70,32 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
Delta? applyRule(Delta document, int index, Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) { {int? len, Object? data, Attribute? attribute}) {
if (data is! String || !data.contains('\n')) { if (data is! String || !data.contains('\n')) {
// Only interested in text containing at least one newline character.
return null; return null;
} }
final itr = DeltaIterator(document)..skip(index); final itr = DeltaIterator(document)..skip(index);
// Look for the next newline.
final nextNewLine = _getNextNewLine(itr); final nextNewLine = _getNextNewLine(itr);
final lineStyle = final lineStyle =
Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>{}); Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>{});
final attribute = lineStyle.getBlockExceptHeader(); final blockStyle = lineStyle.getBlocksExceptHeader();
if (attribute == null) { // Are we currently in a block? If not then ignore.
if (blockStyle.isEmpty) {
return null; return null;
} }
final blockStyle = <String, dynamic>{attribute.key: attribute.value};
Map<String, dynamic>? resetStyle; Map<String, dynamic>? resetStyle;
// If current line had heading style applied to it we'll need to move this
// style to the newly inserted line before it and reset style of the
// original line.
if (lineStyle.containsKey(Attribute.header.key)) { if (lineStyle.containsKey(Attribute.header.key)) {
resetStyle = Attribute.header.toJson(); resetStyle = Attribute.header.toJson();
} }
// Go over each inserted line and ensure block style is applied.
final lines = data.split('\n'); final lines = data.split('\n');
final delta = Delta()..retain(index + (len ?? 0)); final delta = Delta()..retain(index + (len ?? 0));
for (var i = 0; i < lines.length; i++) { for (var i = 0; i < lines.length; i++) {
@ -92,12 +104,15 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
delta.insert(line); delta.insert(line);
} }
if (i == 0) { if (i == 0) {
// The first line should inherit the lineStyle entirely.
delta.insert('\n', lineStyle.toJson()); delta.insert('\n', lineStyle.toJson());
} else if (i < lines.length - 1) { } else if (i < lines.length - 1) {
// we don't want to insert a newline after the last chunk of text, so -1
delta.insert('\n', blockStyle); delta.insert('\n', blockStyle);
} }
} }
// Reset style of the original newline character if needed.
if (resetStyle != null) { if (resetStyle != null) {
delta delta
..retain(nextNewLine.item2!) ..retain(nextNewLine.item2!)
@ -109,6 +124,12 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
} }
} }
/// Heuristic rule to exit current block when user inserts two consecutive
/// newlines.
///
/// This rule is only applied when the cursor is on the last line of a block.
/// When the cursor is in the middle of a block we allow adding empty lines
/// and preserving the block's style.
class AutoExitBlockRule extends InsertRule { class AutoExitBlockRule extends InsertRule {
const AutoExitBlockRule(); const AutoExitBlockRule();
@ -132,25 +153,39 @@ class AutoExitBlockRule extends InsertRule {
final itr = DeltaIterator(document); final itr = DeltaIterator(document);
final prev = itr.skip(index), cur = itr.next(); final prev = itr.skip(index), cur = itr.next();
final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader(); final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader();
// We are not in a block, ignore.
if (cur.isPlain || blockStyle == null) { if (cur.isPlain || blockStyle == null) {
return null; return null;
} }
// We are not on an empty line, ignore.
if (!_isEmptyLine(prev, cur)) { if (!_isEmptyLine(prev, cur)) {
return null; return null;
} }
// We are on an empty line. Now we need to determine if we are on the
// last line of a block.
// First check if `cur` length is greater than 1, this would indicate
// that it contains multiple newline characters which share the same style.
// This would mean we are not on the last line yet.
// `cur.value as String` is safe since we already called isEmptyLine and know it contains a newline
if ((cur.value as String).length > 1) { if ((cur.value as String).length > 1) {
// We are not on the last line of this block, ignore.
return null; return null;
} }
// Keep looking for the next newline character to see if it shares the same
// block style as `cur`.
final nextNewLine = _getNextNewLine(itr); final nextNewLine = _getNextNewLine(itr);
if (nextNewLine.item1 != null && if (nextNewLine.item1 != null &&
nextNewLine.item1!.attributes != null && nextNewLine.item1!.attributes != null &&
Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() == Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() ==
blockStyle) { blockStyle) {
// We are not at the end of this block, ignore.
return null; return null;
} }
// Here we now know that the line after `cur` is not in the same block
// therefore we can exit this block.
final attributes = cur.attributes ?? <String, dynamic>{}; final attributes = cur.attributes ?? <String, dynamic>{};
final k = attributes.keys final k = attributes.keys
.firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k)); .firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k));

@ -11,7 +11,11 @@ import '../models/quill_delta.dart';
import '../utils/diff_delta.dart'; import '../utils/diff_delta.dart';
class QuillController extends ChangeNotifier { class QuillController extends ChangeNotifier {
QuillController({required this.document, required this.selection, this.iconSize = 18, this.toolbarHeightFactor = 2}); QuillController(
{required this.document,
required this.selection,
this.iconSize = 18,
this.toolbarHeightFactor = 2});
factory QuillController.basic() { factory QuillController.basic() {
return QuillController( return QuillController(
@ -28,6 +32,13 @@ class QuillController extends ChangeNotifier {
Style toggledStyle = Style(); Style toggledStyle = Style();
bool ignoreFocusOnTextChange = false; bool ignoreFocusOnTextChange = false;
/// Controls whether this [QuillController] instance has already been disposed
/// of
///
/// This is a safe approach to make sure that listeners don't crash when
/// adding, removing or listeners to this instance.
bool _isDisposed = false;
// item1: Document state before [change]. // item1: Document state before [change].
// //
// item2: Change delta applied to the document. // item2: Change delta applied to the document.
@ -179,9 +190,31 @@ class QuillController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
@override
void addListener(VoidCallback listener) {
// By using `_isDisposed`, make sure that `addListener` won't be called on a
// disposed `ChangeListener`
if (!_isDisposed) {
super.addListener(listener);
}
}
@override
void removeListener(VoidCallback listener) {
// By using `_isDisposed`, make sure that `removeListener` won't be called
// on a disposed `ChangeListener`
if (!_isDisposed) {
super.removeListener(listener);
}
}
@override @override
void dispose() { void dispose() {
if (!_isDisposed) {
document.close(); document.close();
}
_isDisposed = true;
super.dispose(); super.dispose();
} }

@ -176,18 +176,25 @@ class QuillEditor extends StatefulWidget {
final ScrollPhysics? scrollPhysics; final ScrollPhysics? scrollPhysics;
final ValueChanged<String>? onLaunchUrl; final ValueChanged<String>? onLaunchUrl;
// Returns whether gesture is handled // Returns whether gesture is handled
final bool Function(TapDownDetails details, TextPosition Function(Offset offset))? onTapDown; final bool Function(
TapDownDetails details, TextPosition Function(Offset offset))? onTapDown;
// Returns whether gesture is handled // Returns whether gesture is handled
final bool Function(TapUpDetails details, TextPosition Function(Offset offset))? onTapUp; final bool Function(
TapUpDetails details, TextPosition Function(Offset offset))? onTapUp;
// Returns whether gesture is handled // Returns whether gesture is handled
final bool Function(LongPressStartDetails details, TextPosition Function(Offset offset))? onSingleLongTapStart; final bool Function(
LongPressStartDetails details, TextPosition Function(Offset offset))?
onSingleLongTapStart;
// Returns whether gesture is handled // Returns whether gesture is handled
final bool Function(LongPressMoveUpdateDetails details, TextPosition Function(Offset offset))? onSingleLongTapMoveUpdate; final bool Function(LongPressMoveUpdateDetails details,
TextPosition Function(Offset offset))? onSingleLongTapMoveUpdate;
// Returns whether gesture is handled // Returns whether gesture is handled
final bool Function(LongPressEndDetails details, TextPosition Function(Offset offset))? onSingleLongTapEnd; final bool Function(
LongPressEndDetails details, TextPosition Function(Offset offset))?
onSingleLongTapEnd;
final EmbedBuilder embedBuilder; final EmbedBuilder embedBuilder;
@ -339,7 +346,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
if (_state.widget.onSingleLongTapMoveUpdate != null) { if (_state.widget.onSingleLongTapMoveUpdate != null) {
final renderEditor = getRenderEditor(); final renderEditor = getRenderEditor();
if (renderEditor != null) { if (renderEditor != null) {
if (_state.widget.onSingleLongTapMoveUpdate!(details, renderEditor.getPositionForOffset)) { if (_state.widget.onSingleLongTapMoveUpdate!(
details, renderEditor.getPositionForOffset)) {
return; return;
} }
} }
@ -385,8 +393,6 @@ class _QuillEditorSelectionGestureDetectorBuilder
final segmentResult = line.queryChild(result.offset, false); final segmentResult = line.queryChild(result.offset, false);
if (segmentResult.node == null) { if (segmentResult.node == null) {
if (line.length == 1) { if (line.length == 1) {
// tapping when no text yet on this line
_flipListCheckbox(pos, line, segmentResult);
getEditor()!.widget.controller.updateSelection( getEditor()!.widget.controller.updateSelection(
TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL); TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL);
return true; return true;
@ -426,38 +432,10 @@ class _QuillEditorSelectionGestureDetectorBuilder
), ),
); );
} }
return false;
}
if (_flipListCheckbox(pos, line, segmentResult)) {
return true;
}
return false;
} }
bool _flipListCheckbox(
TextPosition pos, Line line, container_node.ChildQuery segmentResult) {
if (getEditor()!.widget.readOnly ||
!line.style.containsKey(Attribute.list.key) ||
segmentResult.offset != 0) {
return false; return false;
} }
// segmentResult.offset == 0 means tap at the beginning of the TextLine
final String? listVal = line.style.attributes[Attribute.list.key]!.value;
if (listVal == Attribute.unchecked.value) {
getEditor()!
.widget
.controller
.formatText(pos.offset, 0, Attribute.checked);
} else if (listVal == Attribute.checked.value) {
getEditor()!
.widget
.controller
.formatText(pos.offset, 0, Attribute.unchecked);
}
getEditor()!.widget.controller.updateSelection(
TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL);
return true;
}
Future<void> _launchUrl(String url) async { Future<void> _launchUrl(String url) async {
await launch(url); await launch(url);
@ -468,7 +446,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
if (_state.widget.onTapDown != null) { if (_state.widget.onTapDown != null) {
final renderEditor = getRenderEditor(); final renderEditor = getRenderEditor();
if (renderEditor != null) { if (renderEditor != null) {
if (_state.widget.onTapDown!(details, renderEditor.getPositionForOffset)) { if (_state.widget.onTapDown!(
details, renderEditor.getPositionForOffset)) {
return; return;
} }
} }
@ -481,7 +460,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
if (_state.widget.onTapUp != null) { if (_state.widget.onTapUp != null) {
final renderEditor = getRenderEditor(); final renderEditor = getRenderEditor();
if (renderEditor != null) { if (renderEditor != null) {
if (_state.widget.onTapUp!(details, renderEditor.getPositionForOffset)) { if (_state.widget.onTapUp!(
details, renderEditor.getPositionForOffset)) {
return; return;
} }
} }
@ -523,7 +503,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
if (_state.widget.onSingleLongTapStart != null) { if (_state.widget.onSingleLongTapStart != null) {
final renderEditor = getRenderEditor(); final renderEditor = getRenderEditor();
if (renderEditor != null) { if (renderEditor != null) {
if (_state.widget.onSingleLongTapStart!(details, renderEditor.getPositionForOffset)) { if (_state.widget.onSingleLongTapStart!(
details, renderEditor.getPositionForOffset)) {
return; return;
} }
} }
@ -557,7 +538,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
if (_state.widget.onSingleLongTapEnd != null) { if (_state.widget.onSingleLongTapEnd != null) {
final renderEditor = getRenderEditor(); final renderEditor = getRenderEditor();
if (renderEditor != null) { if (renderEditor != null) {
if (_state.widget.onSingleLongTapEnd!(details, renderEditor.getPositionForOffset)) { if (_state.widget.onSingleLongTapEnd!(
details, renderEditor.getPositionForOffset)) {
return; return;
} }
} }
@ -1061,7 +1043,6 @@ class RenderEditableContainerBox extends RenderBox
@override @override
void performLayout() { void performLayout() {
assert(!constraints.hasBoundedHeight);
assert(constraints.hasBoundedWidth); assert(constraints.hasBoundedWidth);
_resolvePadding(); _resolvePadding();
assert(_resolvedPadding != null); assert(_resolvedPadding != null);

@ -579,6 +579,18 @@ class RawEditorState extends EditorState
} }
} }
/// Updates the checkbox positioned at [offset] in document
/// by changing its attribute according to [value].
void _handleCheckboxTap(int offset, bool value) {
if (!widget.readOnly) {
if (value) {
widget.controller.formatText(offset, 0, Attribute.checked);
} else {
widget.controller.formatText(offset, 0, Attribute.unchecked);
}
}
}
List<Widget> _buildChildren(Document doc, BuildContext context) { List<Widget> _buildChildren(Document doc, BuildContext context) {
final result = <Widget>[]; final result = <Widget>[];
final indentLevelCounts = <int, int>{}; final indentLevelCounts = <int, int>{};
@ -603,7 +615,9 @@ class RawEditorState extends EditorState
: null, : null,
widget.embedBuilder, widget.embedBuilder,
_cursorCont, _cursorCont,
indentLevelCounts); indentLevelCounts,
_handleCheckboxTap,
);
result.add(editableTextBlock); result.add(editableTextBlock);
} else { } else {
throw StateError('Unreachable.'); throw StateError('Unreachable.');
@ -913,14 +927,16 @@ class RawEditorState extends EditorState
return; return;
} }
_showCaretOnScreen(); _showCaretOnScreen();
_cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, widget.controller.selection); _cursorCont.startOrStopCursorTimerIfNeeded(
_hasFocus, widget.controller.selection);
if (hasConnection) { if (hasConnection) {
_cursorCont _cursorCont
..stopCursorTimer(resetCharTicks: false) ..stopCursorTimer(resetCharTicks: false)
..startCursorTimer(); ..startCursorTimer();
} }
SchedulerBinding.instance!.addPostFrameCallback((_) => _updateOrDisposeSelectionOverlayIfNeeded()); SchedulerBinding.instance!.addPostFrameCallback(
(_) => _updateOrDisposeSelectionOverlayIfNeeded());
if (mounted) { if (mounted) {
setState(() { setState(() {
// Use widget.controller.value in build() // Use widget.controller.value in build()
@ -992,9 +1008,11 @@ class RawEditorState extends EditorState
_showCaretOnScreenScheduled = true; _showCaretOnScreenScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((_) { SchedulerBinding.instance!.addPostFrameCallback((_) {
if (widget.scrollable) {
_showCaretOnScreenScheduled = false; _showCaretOnScreenScheduled = false;
final viewport = RenderAbstractViewport.of(getRenderEditor())!; final viewport = RenderAbstractViewport.of(getRenderEditor());
final editorOffset = getRenderEditor()! final editorOffset = getRenderEditor()!
.localToGlobal(const Offset(0, 0), ancestor: viewport); .localToGlobal(const Offset(0, 0), ancestor: viewport);
final offsetInViewport = _scrollController!.offset + editorOffset.dy; final offsetInViewport = _scrollController!.offset + editorOffset.dy;
@ -1012,6 +1030,7 @@ class RawEditorState extends EditorState
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
); );
} }
}
}); });
} }

@ -0,0 +1,344 @@
import 'dart:convert';
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:string_validator/string_validator.dart';
import 'package:tuple/tuple.dart';
import '../models/documents/attribute.dart';
import '../models/documents/document.dart';
import '../models/documents/nodes/block.dart';
import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/documents/nodes/line.dart';
import 'controller.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'editor.dart';
import 'text_block.dart';
import 'text_line.dart';
class QuillSimpleViewer extends StatefulWidget {
const QuillSimpleViewer({
required this.controller,
this.customStyles,
this.truncate = false,
this.truncateScale,
this.truncateAlignment,
this.truncateHeight,
this.truncateWidth,
this.scrollBottomInset = 0,
this.padding = EdgeInsets.zero,
this.embedBuilder,
Key? key,
}) : assert(truncate ||
((truncateScale == null) &&
(truncateAlignment == null) &&
(truncateHeight == null) &&
(truncateWidth == null))),
super(key: key);
final QuillController controller;
final DefaultStyles? customStyles;
final bool truncate;
final double? truncateScale;
final Alignment? truncateAlignment;
final double? truncateHeight;
final double? truncateWidth;
final double scrollBottomInset;
final EdgeInsetsGeometry padding;
final EmbedBuilder? embedBuilder;
@override
_QuillSimpleViewerState createState() => _QuillSimpleViewerState();
}
class _QuillSimpleViewerState extends State<QuillSimpleViewer>
with SingleTickerProviderStateMixin {
late DefaultStyles _styles;
final LayerLink _toolbarLayerLink = LayerLink();
final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink();
late CursorCont _cursorCont;
@override
void initState() {
super.initState();
_cursorCont = CursorCont(
show: ValueNotifier<bool>(false),
style: const CursorStyle(
color: Colors.black,
backgroundColor: Colors.grey,
width: 2,
radius: Radius.zero,
offset: Offset.zero,
),
tickerProvider: this,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final parentStyles = QuillStyles.getStyles(context, true);
final defaultStyles = DefaultStyles.getInstance(context);
_styles = (parentStyles != null)
? defaultStyles.merge(parentStyles)
: defaultStyles;
if (widget.customStyles != null) {
_styles = _styles.merge(widget.customStyles!);
}
}
EmbedBuilder get embedBuilder => widget.embedBuilder ?? _defaultEmbedBuilder;
Widget _defaultEmbedBuilder(BuildContext context, leaf.Embed node) {
assert(!kIsWeb, 'Please provide EmbedBuilder for Web');
switch (node.value.type) {
case 'image':
final imageUrl = _standardizeImageUrl(node.value.data);
return imageUrl.startsWith('http')
? Image.network(imageUrl)
: isBase64(imageUrl)
? Image.memory(base64.decode(imageUrl))
: Image.file(io.File(imageUrl));
default:
throw UnimplementedError(
'Embeddable type "${node.value.type}" is not supported by default embed '
'builder of QuillEditor. You must pass your own builder function to '
'embedBuilder property of QuillEditor or QuillField widgets.');
}
}
String _standardizeImageUrl(String url) {
if (url.contains('base64')) {
return url.split(',')[1];
}
return url;
}
@override
Widget build(BuildContext context) {
final _doc = widget.controller.document;
// if (_doc.isEmpty() &&
// !widget.focusNode.hasFocus &&
// widget.placeholder != null) {
// _doc = Document.fromJson(jsonDecode(
// '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]'));
// }
Widget child = CompositedTransformTarget(
link: _toolbarLayerLink,
child: Semantics(
child: _SimpleViewer(
document: _doc,
textDirection: _textDirection,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
onSelectionChanged: _nullSelectionChanged,
scrollBottomInset: widget.scrollBottomInset,
padding: widget.padding,
children: _buildChildren(_doc, context),
),
),
);
if (widget.truncate) {
if (widget.truncateScale != null) {
child = Container(
height: widget.truncateHeight,
child: Align(
heightFactor: widget.truncateScale,
widthFactor: widget.truncateScale,
alignment: widget.truncateAlignment ?? Alignment.topLeft,
child: Container(
width: widget.truncateWidth! / widget.truncateScale!,
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Transform.scale(
scale: widget.truncateScale!,
alignment:
widget.truncateAlignment ?? Alignment.topLeft,
child: child)))));
} else {
child = Container(
height: widget.truncateHeight,
width: widget.truncateWidth,
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(), child: child));
}
}
return QuillStyles(data: _styles, child: child);
}
List<Widget> _buildChildren(Document doc, BuildContext context) {
final result = <Widget>[];
final indentLevelCounts = <int, int>{};
for (final node in doc.root.children) {
if (node is Line) {
final editableTextLine = _getEditableTextLineFromNode(node, context);
result.add(editableTextLine);
} else if (node is Block) {
final attrs = node.style.attributes;
final editableTextBlock = EditableTextBlock(
node,
_textDirection,
widget.scrollBottomInset,
_getVerticalSpacingForBlock(node, _styles),
widget.controller.selection,
Colors.black,
// selectionColor,
_styles,
false,
// enableInteractiveSelection,
false,
// hasFocus,
attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
embedBuilder,
_cursorCont,
indentLevelCounts,
_handleCheckboxTap);
result.add(editableTextBlock);
} else {
throw StateError('Unreachable.');
}
}
return result;
}
/// Updates the checkbox positioned at [offset] in document
/// by changing its attribute according to [value].
void _handleCheckboxTap(int offset, bool value) {
// readonly - do nothing
}
TextDirection get _textDirection {
final result = Directionality.of(context);
return result;
}
EditableTextLine _getEditableTextLineFromNode(
Line node, BuildContext context) {
final textLine = TextLine(
line: node,
textDirection: _textDirection,
embedBuilder: embedBuilder,
styles: _styles,
);
final editableTextLine = EditableTextLine(
node,
null,
textLine,
0,
_getVerticalSpacingForLine(node, _styles),
_textDirection,
widget.controller.selection,
Colors.black,
//widget.selectionColor,
false,
//enableInteractiveSelection,
false,
//_hasFocus,
MediaQuery.of(context).devicePixelRatio,
_cursorCont);
return editableTextLine;
}
Tuple2<double, double> _getVerticalSpacingForLine(
Line line, DefaultStyles? defaultStyles) {
final attrs = line.style.attributes;
if (attrs.containsKey(Attribute.header.key)) {
final int? level = attrs[Attribute.header.key]!.value;
switch (level) {
case 1:
return defaultStyles!.h1!.verticalSpacing;
case 2:
return defaultStyles!.h2!.verticalSpacing;
case 3:
return defaultStyles!.h3!.verticalSpacing;
default:
throw 'Invalid level $level';
}
}
return defaultStyles!.paragraph!.verticalSpacing;
}
Tuple2<double, double> _getVerticalSpacingForBlock(
Block node, DefaultStyles? defaultStyles) {
final attrs = node.style.attributes;
if (attrs.containsKey(Attribute.blockQuote.key)) {
return defaultStyles!.quote!.verticalSpacing;
} else if (attrs.containsKey(Attribute.codeBlock.key)) {
return defaultStyles!.code!.verticalSpacing;
} else if (attrs.containsKey(Attribute.indent.key)) {
return defaultStyles!.indent!.verticalSpacing;
}
return defaultStyles!.lists!.verticalSpacing;
}
void _nullSelectionChanged(
TextSelection selection, SelectionChangedCause cause) {}
}
class _SimpleViewer extends MultiChildRenderObjectWidget {
_SimpleViewer({
required List<Widget> children,
required this.document,
required this.textDirection,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required this.onSelectionChanged,
required this.scrollBottomInset,
this.padding = EdgeInsets.zero,
Key? key,
}) : super(key: key, children: children);
final Document document;
final TextDirection textDirection;
final LayerLink startHandleLayerLink;
final LayerLink endHandleLayerLink;
final TextSelectionChangedHandler onSelectionChanged;
final double scrollBottomInset;
final EdgeInsetsGeometry padding;
@override
RenderEditor createRenderObject(BuildContext context) {
return RenderEditor(
null,
textDirection,
scrollBottomInset,
padding,
document,
const TextSelection(baseOffset: 0, extentOffset: 0),
false,
// hasFocus,
onSelectionChanged,
startHandleLayerLink,
endHandleLayerLink,
const EdgeInsets.fromLTRB(4, 4, 4, 5),
);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderEditor renderObject) {
renderObject
..document = document
..setContainer(document.root)
..textDirection = textDirection
..setStartHandleLayerLink(startHandleLayerLink)
..setEndHandleLayerLink(endHandleLayerLink)
..onSelectionChanged = onSelectionChanged
..setScrollBottomInset(scrollBottomInset)
..setPadding(padding);
}
}

@ -61,6 +61,7 @@ class EditableTextBlock extends StatelessWidget {
this.embedBuilder, this.embedBuilder,
this.cursorCont, this.cursorCont,
this.indentLevelCounts, this.indentLevelCounts,
this.onCheckboxTap,
); );
final Block block; final Block block;
@ -76,6 +77,7 @@ class EditableTextBlock extends StatelessWidget {
final EmbedBuilder embedBuilder; final EmbedBuilder embedBuilder;
final CursorCont cursorCont; final CursorCont cursorCont;
final Map<int, int> indentLevelCounts; final Map<int, int> indentLevelCounts;
final Function(int, bool) onCheckboxTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -161,12 +163,23 @@ class EditableTextBlock extends StatelessWidget {
if (attrs[Attribute.list.key] == Attribute.checked) { if (attrs[Attribute.list.key] == Attribute.checked) {
return _Checkbox( return _Checkbox(
style: defaultStyles!.leading!.style, width: 32, isChecked: true); key: UniqueKey(),
style: defaultStyles!.leading!.style,
width: 32,
isChecked: true,
offset: block.offset + line.offset,
onTap: onCheckboxTap,
);
} }
if (attrs[Attribute.list.key] == Attribute.unchecked) { if (attrs[Attribute.list.key] == Attribute.unchecked) {
return _Checkbox( return _Checkbox(
style: defaultStyles!.leading!.style, width: 32, isChecked: false); key: UniqueKey(),
style: defaultStyles!.leading!.style,
width: 32,
offset: block.offset + line.offset,
onTap: onCheckboxTap,
);
} }
if (attrs.containsKey(Attribute.codeBlock.key)) { if (attrs.containsKey(Attribute.codeBlock.key)) {
@ -685,47 +698,40 @@ class _BulletPoint extends StatelessWidget {
} }
} }
class _Checkbox extends StatefulWidget { class _Checkbox extends StatelessWidget {
const _Checkbox({Key? key, this.style, this.width, this.isChecked}) const _Checkbox({
: super(key: key); Key? key,
this.style,
this.width,
this.isChecked = false,
this.offset,
this.onTap,
}) : super(key: key);
final TextStyle? style; final TextStyle? style;
final double? width; final double? width;
final bool? isChecked; final bool isChecked;
final int? offset;
final Function(int, bool)? onTap;
@override void _onCheckboxClicked(bool? newValue) {
__CheckboxState createState() => __CheckboxState(); if (onTap != null && newValue != null && offset != null) {
onTap!(offset!, newValue);
} }
class __CheckboxState extends State<_Checkbox> {
bool? isChecked;
void _onCheckboxClicked(bool? newValue) => setState(() {
isChecked = newValue;
if (isChecked!) {
// check list
} else {
// uncheck list
}
});
@override
void initState() {
super.initState();
isChecked = widget.isChecked;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
alignment: AlignmentDirectional.topEnd, alignment: AlignmentDirectional.topEnd,
width: widget.width, width: width,
padding: const EdgeInsetsDirectional.only(end: 13), padding: const EdgeInsetsDirectional.only(end: 13),
child: GestureDetector(
onLongPress: () => _onCheckboxClicked(!isChecked),
child: Checkbox( child: Checkbox(
value: widget.isChecked, value: isChecked,
onChanged: _onCheckboxClicked, onChanged: _onCheckboxClicked,
), ),
),
); );
} }
} }

@ -432,7 +432,8 @@ class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _selectHeadingStyleButtonBuilder(context, _value, _selectAttribute, widget.controller.iconSize); return _selectHeadingStyleButtonBuilder(
context, _value, _selectAttribute, widget.controller.iconSize);
} }
} }
@ -774,7 +775,8 @@ class _HistoryButtonState extends State<HistoryButton> {
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: widget.controller.iconSize * 1.77, size: widget.controller.iconSize * 1.77,
icon: Icon(widget.icon, size: widget.controller.iconSize, color: _iconColor), icon: Icon(widget.icon,
size: widget.controller.iconSize, color: _iconColor),
fillColor: fillColor, fillColor: fillColor,
onPressed: _changeHistory, onPressed: _changeHistory,
); );
@ -839,7 +841,8 @@ class _IndentButtonState extends State<IndentButton> {
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: widget.controller.iconSize * 1.77, size: widget.controller.iconSize * 1.77,
icon: Icon(widget.icon, size: widget.controller.iconSize, color: iconColor), icon:
Icon(widget.icon, size: widget.controller.iconSize, color: iconColor),
fillColor: fillColor, fillColor: fillColor,
onPressed: () { onPressed: () {
final indent = widget.controller final indent = widget.controller
@ -893,7 +896,8 @@ class _ClearFormatButtonState extends State<ClearFormatButton> {
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: widget.controller.iconSize * 1.77, size: widget.controller.iconSize * 1.77,
icon: Icon(widget.icon, size: widget.controller.iconSize, color: iconColor), icon: Icon(widget.icon,
size: widget.controller.iconSize, color: iconColor),
fillColor: fillColor, fillColor: fillColor,
onPressed: () { onPressed: () {
for (final k for (final k
@ -905,7 +909,9 @@ class _ClearFormatButtonState extends State<ClearFormatButton> {
} }
class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { class QuillToolbar extends StatefulWidget implements PreferredSizeWidget {
const QuillToolbar({required this.children, this.toolBarHeight = 36, Key? key}) : super(key: key); const QuillToolbar(
{required this.children, this.toolBarHeight = 36, Key? key})
: super(key: key);
factory QuillToolbar.basic({ factory QuillToolbar.basic({
required QuillController controller, required QuillController controller,
@ -932,7 +938,10 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget {
}) { }) {
controller.iconSize = toolbarIconSize; controller.iconSize = toolbarIconSize;
return QuillToolbar(key: key, toolBarHeight: toolbarIconSize * controller.toolbarHeightFactor, children: [ return QuillToolbar(
key: key,
toolBarHeight: toolbarIconSize * controller.toolbarHeightFactor,
children: [
Visibility( Visibility(
visible: showHistory, visible: showHistory,
child: HistoryButton( child: HistoryButton(
@ -1032,9 +1041,14 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget {
), ),
), ),
Visibility( Visibility(
visible: showHeaderStyle, child: VerticalDivider(indent: 12, endIndent: 12, color: Colors.grey.shade400)), visible: showHeaderStyle,
Visibility(visible: showHeaderStyle, child: SelectHeaderStyleButton(controller: controller)), child: VerticalDivider(
VerticalDivider(indent: 12, endIndent: 12, color: Colors.grey.shade400), indent: 12, endIndent: 12, color: Colors.grey.shade400)),
Visibility(
visible: showHeaderStyle,
child: SelectHeaderStyleButton(controller: controller)),
VerticalDivider(
indent: 12, endIndent: 12, color: Colors.grey.shade400),
Visibility( Visibility(
visible: showListNumbers, visible: showListNumbers,
child: ToggleStyleButton( child: ToggleStyleButton(
@ -1068,8 +1082,12 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget {
), ),
), ),
Visibility( Visibility(
visible: !showListNumbers && !showListBullets && !showListCheck && !showCodeBlock, visible: !showListNumbers &&
child: VerticalDivider(indent: 12, endIndent: 12, color: Colors.grey.shade400)), !showListBullets &&
!showListCheck &&
!showCodeBlock,
child: VerticalDivider(
indent: 12, endIndent: 12, color: Colors.grey.shade400)),
Visibility( Visibility(
visible: showQuote, visible: showQuote,
child: ToggleStyleButton( child: ToggleStyleButton(
@ -1094,8 +1112,13 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget {
isIncrease: false, isIncrease: false,
), ),
), ),
Visibility(visible: showQuote, child: VerticalDivider(indent: 12, endIndent: 12, color: Colors.grey.shade400)), Visibility(
Visibility(visible: showLink, child: LinkStyleButton(controller: controller)), visible: showQuote,
child: VerticalDivider(
indent: 12, endIndent: 12, color: Colors.grey.shade400)),
Visibility(
visible: showLink,
child: LinkStyleButton(controller: controller)),
Visibility( Visibility(
visible: showHorizontalRule, visible: showHorizontalRule,
child: InsertEmbedButton( child: InsertEmbedButton(

@ -1,6 +1,6 @@
name: flutter_quill name: flutter_quill
description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us)
version: 1.2.0 version: 1.2.2
#author: bulletjournal #author: bulletjournal
homepage: https://bulletjournal.us/home/index.html homepage: https://bulletjournal.us/home/index.html
repository: https://github.com/singerdmx/flutter-quill repository: https://github.com/singerdmx/flutter-quill

Loading…
Cancel
Save