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. 37
      lib/models/documents/attribute.dart
  4. 5
      lib/models/documents/document.dart
  5. 24
      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. 37
      lib/widgets/controller.dart
  10. 65
      lib/widgets/editor.dart
  11. 83
      lib/widgets/raw_editor.dart
  12. 344
      lib/widgets/simple_viewer.dart
  13. 70
      lib/widgets/text_block.dart
  14. 377
      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.
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]
* Fix image button cancel causes crash.

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

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

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

@ -30,7 +30,8 @@ class Style {
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;
@ -48,6 +49,11 @@ class Style {
bool containsKey(String key) => _attributes.containsKey(key);
Attribute? getBlockExceptHeader() {
for (final val in values) {
if (val.isBlockExceptHeader && val.value != null) {
return val;
}
}
for (final val in values) {
if (val.isBlockExceptHeader) {
return val;
@ -56,6 +62,16 @@ class Style {
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) {
final merged = Map<String, Attribute>.from(_attributes);
if (attribute.value == null) {

@ -265,7 +265,7 @@ class Delta {
List<Operation> toList() => List.from(_operations);
/// 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.
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 {
const PreserveBlockStyleOnInsertRule();
@ -62,28 +70,32 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || !data.contains('\n')) {
// Only interested in text containing at least one newline character.
return null;
}
final itr = DeltaIterator(document)..skip(index);
// Look for the next newline.
final nextNewLine = _getNextNewLine(itr);
final lineStyle =
Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>{});
final attribute = lineStyle.getBlockExceptHeader();
if (attribute == null) {
final blockStyle = lineStyle.getBlocksExceptHeader();
// Are we currently in a block? If not then ignore.
if (blockStyle.isEmpty) {
return null;
}
final blockStyle = <String, dynamic>{attribute.key: attribute.value};
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)) {
resetStyle = Attribute.header.toJson();
}
// Go over each inserted line and ensure block style is applied.
final lines = data.split('\n');
final delta = Delta()..retain(index + (len ?? 0));
for (var i = 0; i < lines.length; i++) {
@ -92,12 +104,15 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
delta.insert(line);
}
if (i == 0) {
// The first line should inherit the lineStyle entirely.
delta.insert('\n', lineStyle.toJson());
} 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);
}
}
// Reset style of the original newline character if needed.
if (resetStyle != null) {
delta
..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 {
const AutoExitBlockRule();
@ -132,25 +153,39 @@ class AutoExitBlockRule extends InsertRule {
final itr = DeltaIterator(document);
final prev = itr.skip(index), cur = itr.next();
final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader();
// We are not in a block, ignore.
if (cur.isPlain || blockStyle == null) {
return null;
}
// We are not on an empty line, ignore.
if (!_isEmptyLine(prev, cur)) {
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) {
// We are not on the last line of this block, ignore.
return null;
}
// Keep looking for the next newline character to see if it shares the same
// block style as `cur`.
final nextNewLine = _getNextNewLine(itr);
if (nextNewLine.item1 != null &&
nextNewLine.item1!.attributes != null &&
Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() ==
blockStyle) {
// We are not at the end of this block, ignore.
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 k = attributes.keys
.firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k));

@ -11,7 +11,11 @@ import '../models/quill_delta.dart';
import '../utils/diff_delta.dart';
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() {
return QuillController(
@ -28,6 +32,13 @@ class QuillController extends ChangeNotifier {
Style toggledStyle = Style();
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].
//
// item2: Change delta applied to the document.
@ -179,9 +190,31 @@ class QuillController extends ChangeNotifier {
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
void dispose() {
document.close();
if (!_isDisposed) {
document.close();
}
_isDisposed = true;
super.dispose();
}

@ -176,18 +176,25 @@ class QuillEditor extends StatefulWidget {
final ScrollPhysics? scrollPhysics;
final ValueChanged<String>? onLaunchUrl;
// 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
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
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
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
final bool Function(LongPressEndDetails details, TextPosition Function(Offset offset))? onSingleLongTapEnd;
final bool Function(
LongPressEndDetails details, TextPosition Function(Offset offset))?
onSingleLongTapEnd;
final EmbedBuilder embedBuilder;
@ -339,7 +346,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
if (_state.widget.onSingleLongTapMoveUpdate != null) {
final renderEditor = getRenderEditor();
if (renderEditor != null) {
if (_state.widget.onSingleLongTapMoveUpdate!(details, renderEditor.getPositionForOffset)) {
if (_state.widget.onSingleLongTapMoveUpdate!(
details, renderEditor.getPositionForOffset)) {
return;
}
}
@ -385,8 +393,6 @@ class _QuillEditorSelectionGestureDetectorBuilder
final segmentResult = line.queryChild(result.offset, false);
if (segmentResult.node == null) {
if (line.length == 1) {
// tapping when no text yet on this line
_flipListCheckbox(pos, line, segmentResult);
getEditor()!.widget.controller.updateSelection(
TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL);
return true;
@ -426,37 +432,9 @@ 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;
}
// 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;
return false;
}
Future<void> _launchUrl(String url) async {
@ -468,7 +446,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
if (_state.widget.onTapDown != null) {
final renderEditor = getRenderEditor();
if (renderEditor != null) {
if (_state.widget.onTapDown!(details, renderEditor.getPositionForOffset)) {
if (_state.widget.onTapDown!(
details, renderEditor.getPositionForOffset)) {
return;
}
}
@ -481,7 +460,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
if (_state.widget.onTapUp != null) {
final renderEditor = getRenderEditor();
if (renderEditor != null) {
if (_state.widget.onTapUp!(details, renderEditor.getPositionForOffset)) {
if (_state.widget.onTapUp!(
details, renderEditor.getPositionForOffset)) {
return;
}
}
@ -523,7 +503,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
if (_state.widget.onSingleLongTapStart != null) {
final renderEditor = getRenderEditor();
if (renderEditor != null) {
if (_state.widget.onSingleLongTapStart!(details, renderEditor.getPositionForOffset)) {
if (_state.widget.onSingleLongTapStart!(
details, renderEditor.getPositionForOffset)) {
return;
}
}
@ -557,7 +538,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
if (_state.widget.onSingleLongTapEnd != null) {
final renderEditor = getRenderEditor();
if (renderEditor != null) {
if (_state.widget.onSingleLongTapEnd!(details, renderEditor.getPositionForOffset)) {
if (_state.widget.onSingleLongTapEnd!(
details, renderEditor.getPositionForOffset)) {
return;
}
}
@ -1061,7 +1043,6 @@ class RenderEditableContainerBox extends RenderBox
@override
void performLayout() {
assert(!constraints.hasBoundedHeight);
assert(constraints.hasBoundedWidth);
_resolvePadding();
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) {
final result = <Widget>[];
final indentLevelCounts = <int, int>{};
@ -589,21 +601,23 @@ class RawEditorState extends EditorState
} else if (node is Block) {
final attrs = node.style.attributes;
final editableTextBlock = EditableTextBlock(
node,
_textDirection,
widget.scrollBottomInset,
_getVerticalSpacingForBlock(node, _styles),
widget.controller.selection,
widget.selectionColor,
_styles,
widget.enableInteractiveSelection,
_hasFocus,
attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
widget.embedBuilder,
_cursorCont,
indentLevelCounts);
node,
_textDirection,
widget.scrollBottomInset,
_getVerticalSpacingForBlock(node, _styles),
widget.controller.selection,
widget.selectionColor,
_styles,
widget.enableInteractiveSelection,
_hasFocus,
attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
widget.embedBuilder,
_cursorCont,
indentLevelCounts,
_handleCheckboxTap,
);
result.add(editableTextBlock);
} else {
throw StateError('Unreachable.');
@ -913,14 +927,16 @@ class RawEditorState extends EditorState
return;
}
_showCaretOnScreen();
_cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, widget.controller.selection);
_cursorCont.startOrStopCursorTimerIfNeeded(
_hasFocus, widget.controller.selection);
if (hasConnection) {
_cursorCont
..stopCursorTimer(resetCharTicks: false)
..startCursorTimer();
}
SchedulerBinding.instance!.addPostFrameCallback((_) => _updateOrDisposeSelectionOverlayIfNeeded());
SchedulerBinding.instance!.addPostFrameCallback(
(_) => _updateOrDisposeSelectionOverlayIfNeeded());
if (mounted) {
setState(() {
// Use widget.controller.value in build()
@ -992,25 +1008,28 @@ class RawEditorState extends EditorState
_showCaretOnScreenScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((_) {
_showCaretOnScreenScheduled = false;
if (widget.scrollable) {
_showCaretOnScreenScheduled = false;
final viewport = RenderAbstractViewport.of(getRenderEditor())!;
final editorOffset = getRenderEditor()!
.localToGlobal(const Offset(0, 0), ancestor: viewport);
final offsetInViewport = _scrollController!.offset + editorOffset.dy;
final viewport = RenderAbstractViewport.of(getRenderEditor());
final offset = getRenderEditor()!.getOffsetToRevealCursor(
_scrollController!.position.viewportDimension,
_scrollController!.offset,
offsetInViewport,
);
final editorOffset = getRenderEditor()!
.localToGlobal(const Offset(0, 0), ancestor: viewport);
final offsetInViewport = _scrollController!.offset + editorOffset.dy;
if (offset != null) {
_scrollController!.animateTo(
offset,
duration: const Duration(milliseconds: 100),
curve: Curves.fastOutSlowIn,
final offset = getRenderEditor()!.getOffsetToRevealCursor(
_scrollController!.position.viewportDimension,
_scrollController!.offset,
offsetInViewport,
);
if (offset != null) {
_scrollController!.animateTo(
offset,
duration: const Duration(milliseconds: 100),
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.cursorCont,
this.indentLevelCounts,
this.onCheckboxTap,
);
final Block block;
@ -76,6 +77,7 @@ class EditableTextBlock extends StatelessWidget {
final EmbedBuilder embedBuilder;
final CursorCont cursorCont;
final Map<int, int> indentLevelCounts;
final Function(int, bool) onCheckboxTap;
@override
Widget build(BuildContext context) {
@ -161,12 +163,23 @@ class EditableTextBlock extends StatelessWidget {
if (attrs[Attribute.list.key] == Attribute.checked) {
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) {
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)) {
@ -685,46 +698,39 @@ class _BulletPoint extends StatelessWidget {
}
}
class _Checkbox extends StatefulWidget {
const _Checkbox({Key? key, this.style, this.width, this.isChecked})
: super(key: key);
class _Checkbox extends StatelessWidget {
const _Checkbox({
Key? key,
this.style,
this.width,
this.isChecked = false,
this.offset,
this.onTap,
}) : super(key: key);
final TextStyle? style;
final double? width;
final bool? isChecked;
final bool isChecked;
final int? offset;
final Function(int, bool)? onTap;
@override
__CheckboxState createState() => __CheckboxState();
}
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;
void _onCheckboxClicked(bool? newValue) {
if (onTap != null && newValue != null && offset != null) {
onTap!(offset!, newValue);
}
}
@override
Widget build(BuildContext context) {
return Container(
alignment: AlignmentDirectional.topEnd,
width: widget.width,
width: width,
padding: const EdgeInsetsDirectional.only(end: 13),
child: Checkbox(
value: widget.isChecked,
onChanged: _onCheckboxClicked,
child: GestureDetector(
onLongPress: () => _onCheckboxClicked(!isChecked),
child: Checkbox(
value: isChecked,
onChanged: _onCheckboxClicked,
),
),
);
}

@ -432,7 +432,8 @@ class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> {
@override
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,
hoverElevation: 0,
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,
onPressed: _changeHistory,
);
@ -839,7 +841,8 @@ class _IndentButtonState extends State<IndentButton> {
highlightElevation: 0,
hoverElevation: 0,
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,
onPressed: () {
final indent = widget.controller
@ -893,7 +896,8 @@ class _ClearFormatButtonState extends State<ClearFormatButton> {
highlightElevation: 0,
hoverElevation: 0,
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,
onPressed: () {
for (final k
@ -905,7 +909,9 @@ class _ClearFormatButtonState extends State<ClearFormatButton> {
}
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({
required QuillController controller,
@ -932,178 +938,195 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget {
}) {
controller.iconSize = toolbarIconSize;
return QuillToolbar(key: key, toolBarHeight: toolbarIconSize * controller.toolbarHeightFactor, children: [
Visibility(
visible: showHistory,
child: HistoryButton(
icon: Icons.undo_outlined,
controller: controller,
undo: true,
),
),
Visibility(
visible: showHistory,
child: HistoryButton(
icon: Icons.redo_outlined,
controller: controller,
undo: false,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showBoldButton,
child: ToggleStyleButton(
attribute: Attribute.bold,
icon: Icons.format_bold,
controller: controller,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showItalicButton,
child: ToggleStyleButton(
attribute: Attribute.italic,
icon: Icons.format_italic,
controller: controller,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showUnderLineButton,
child: ToggleStyleButton(
attribute: Attribute.underline,
icon: Icons.format_underline,
controller: controller,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showStrikeThrough,
child: ToggleStyleButton(
attribute: Attribute.strikeThrough,
icon: Icons.format_strikethrough,
controller: controller,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showColorButton,
child: ColorButton(
icon: Icons.color_lens,
controller: controller,
background: false,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showBackgroundColorButton,
child: ColorButton(
icon: Icons.format_color_fill,
controller: controller,
background: true,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showClearFormat,
child: ClearFormatButton(
icon: Icons.format_clear,
controller: controller,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: onImagePickCallback != null,
child: ImageButton(
icon: Icons.image,
controller: controller,
imageSource: ImageSource.gallery,
onImagePickCallback: onImagePickCallback,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: onImagePickCallback != null,
child: ImageButton(
icon: Icons.photo_camera,
controller: controller,
imageSource: ImageSource.camera,
onImagePickCallback: onImagePickCallback,
),
),
Visibility(
visible: showHeaderStyle, child: VerticalDivider(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(
visible: showListNumbers,
child: ToggleStyleButton(
attribute: Attribute.ol,
controller: controller,
icon: Icons.format_list_numbered,
),
),
Visibility(
visible: showListBullets,
child: ToggleStyleButton(
attribute: Attribute.ul,
controller: controller,
icon: Icons.format_list_bulleted,
),
),
Visibility(
visible: showListCheck,
child: ToggleCheckListButton(
attribute: Attribute.unchecked,
controller: controller,
icon: Icons.check_box,
),
),
Visibility(
visible: showCodeBlock,
child: ToggleStyleButton(
attribute: Attribute.codeBlock,
controller: controller,
icon: Icons.code,
),
),
Visibility(
visible: !showListNumbers && !showListBullets && !showListCheck && !showCodeBlock,
child: VerticalDivider(indent: 12, endIndent: 12, color: Colors.grey.shade400)),
Visibility(
visible: showQuote,
child: ToggleStyleButton(
attribute: Attribute.blockQuote,
controller: controller,
icon: Icons.format_quote,
),
),
Visibility(
visible: showIndent,
child: IndentButton(
icon: Icons.format_indent_increase,
controller: controller,
isIncrease: true,
),
),
Visibility(
visible: showIndent,
child: IndentButton(
icon: Icons.format_indent_decrease,
controller: controller,
isIncrease: false,
),
),
Visibility(visible: showQuote, child: VerticalDivider(indent: 12, endIndent: 12, color: Colors.grey.shade400)),
Visibility(visible: showLink, child: LinkStyleButton(controller: controller)),
Visibility(
visible: showHorizontalRule,
child: InsertEmbedButton(
controller: controller,
icon: Icons.horizontal_rule,
),
),
]);
return QuillToolbar(
key: key,
toolBarHeight: toolbarIconSize * controller.toolbarHeightFactor,
children: [
Visibility(
visible: showHistory,
child: HistoryButton(
icon: Icons.undo_outlined,
controller: controller,
undo: true,
),
),
Visibility(
visible: showHistory,
child: HistoryButton(
icon: Icons.redo_outlined,
controller: controller,
undo: false,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showBoldButton,
child: ToggleStyleButton(
attribute: Attribute.bold,
icon: Icons.format_bold,
controller: controller,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showItalicButton,
child: ToggleStyleButton(
attribute: Attribute.italic,
icon: Icons.format_italic,
controller: controller,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showUnderLineButton,
child: ToggleStyleButton(
attribute: Attribute.underline,
icon: Icons.format_underline,
controller: controller,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showStrikeThrough,
child: ToggleStyleButton(
attribute: Attribute.strikeThrough,
icon: Icons.format_strikethrough,
controller: controller,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showColorButton,
child: ColorButton(
icon: Icons.color_lens,
controller: controller,
background: false,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showBackgroundColorButton,
child: ColorButton(
icon: Icons.format_color_fill,
controller: controller,
background: true,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: showClearFormat,
child: ClearFormatButton(
icon: Icons.format_clear,
controller: controller,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: onImagePickCallback != null,
child: ImageButton(
icon: Icons.image,
controller: controller,
imageSource: ImageSource.gallery,
onImagePickCallback: onImagePickCallback,
),
),
const SizedBox(width: 0.6),
Visibility(
visible: onImagePickCallback != null,
child: ImageButton(
icon: Icons.photo_camera,
controller: controller,
imageSource: ImageSource.camera,
onImagePickCallback: onImagePickCallback,
),
),
Visibility(
visible: showHeaderStyle,
child: VerticalDivider(
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(
visible: showListNumbers,
child: ToggleStyleButton(
attribute: Attribute.ol,
controller: controller,
icon: Icons.format_list_numbered,
),
),
Visibility(
visible: showListBullets,
child: ToggleStyleButton(
attribute: Attribute.ul,
controller: controller,
icon: Icons.format_list_bulleted,
),
),
Visibility(
visible: showListCheck,
child: ToggleCheckListButton(
attribute: Attribute.unchecked,
controller: controller,
icon: Icons.check_box,
),
),
Visibility(
visible: showCodeBlock,
child: ToggleStyleButton(
attribute: Attribute.codeBlock,
controller: controller,
icon: Icons.code,
),
),
Visibility(
visible: !showListNumbers &&
!showListBullets &&
!showListCheck &&
!showCodeBlock,
child: VerticalDivider(
indent: 12, endIndent: 12, color: Colors.grey.shade400)),
Visibility(
visible: showQuote,
child: ToggleStyleButton(
attribute: Attribute.blockQuote,
controller: controller,
icon: Icons.format_quote,
),
),
Visibility(
visible: showIndent,
child: IndentButton(
icon: Icons.format_indent_increase,
controller: controller,
isIncrease: true,
),
),
Visibility(
visible: showIndent,
child: IndentButton(
icon: Icons.format_indent_decrease,
controller: controller,
isIncrease: false,
),
),
Visibility(
visible: showQuote,
child: VerticalDivider(
indent: 12, endIndent: 12, color: Colors.grey.shade400)),
Visibility(
visible: showLink,
child: LinkStyleButton(controller: controller)),
Visibility(
visible: showHorizontalRule,
child: InsertEmbedButton(
controller: controller,
icon: Icons.horizontal_rule,
),
),
]);
}
final List<Widget> children;

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

Loading…
Cancel
Save