pull/226/head
Kevin Despoulains 4 years ago
commit 1d22c2a534
  1. 3
      CHANGELOG.md
  2. 31
      lib/models/documents/document.dart
  3. 8
      lib/models/rules/rule.dart
  4. 4
      lib/widgets/controller.dart
  5. 1
      lib/widgets/editor.dart
  6. 33
      lib/widgets/raw_editor.dart
  7. 344
      lib/widgets/simple_viewer.dart
  8. 2
      pubspec.yaml

@ -1,3 +1,6 @@
## [1.2.2]
* Checkbox supports tapping.
## [1.2.1]
* Indented position not holding while editing.

@ -40,6 +40,10 @@ class Document {
final Rules _rules = Rules.getInstance();
void setCustomRules(List<Rule> customRules) {
_rules.setCustomRules(customRules);
}
final StreamController<Tuple3<Delta, Delta, ChangeSource>> _observer =
StreamController.broadcast();
@ -47,7 +51,7 @@ class Document {
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => _observer.stream;
Delta insert(int index, Object? data, {int replaceLength = 0}) {
Delta insert(int index, Object? data, {int replaceLength = 0, bool autoAppendNewlineAfterImage = true}) {
assert(index >= 0);
assert(data is String || data is Embeddable);
if (data is Embeddable) {
@ -58,7 +62,7 @@ class Document {
final delta = _rules.apply(RuleType.INSERT, this, index,
data: data, len: replaceLength);
compose(delta, ChangeSource.LOCAL);
compose(delta, ChangeSource.LOCAL, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
return delta;
}
@ -71,7 +75,7 @@ class Document {
return delta;
}
Delta replace(int index, int len, Object? data) {
Delta replace(int index, int len, Object? data, {bool autoAppendNewlineAfterImage = true}) {
assert(index >= 0);
assert(data is String || data is Embeddable);
@ -84,7 +88,8 @@ class Document {
// We have to insert before applying delete rules
// Otherwise delete would be operating on stale document snapshot.
if (dataIsNotEmpty) {
delta = insert(index, data, replaceLength: len);
delta = insert(index, data, replaceLength: len,
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
}
if (len > 0) {
@ -124,13 +129,13 @@ class Document {
return block.queryChild(res.offset, true);
}
void compose(Delta delta, ChangeSource changeSource) {
void compose(Delta delta, ChangeSource changeSource, {bool autoAppendNewlineAfterImage = true}) {
assert(!_observer.isClosed);
delta.trim();
assert(delta.isNotEmpty);
var offset = 0;
delta = _transform(delta);
delta = _transform(delta, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
final originalDelta = toDelta();
for (final op in delta.toList()) {
final style =
@ -174,22 +179,28 @@ class Document {
bool get hasRedo => _history.hasRedo;
static Delta _transform(Delta delta) {
static Delta _transform(Delta delta, {bool autoAppendNewlineAfterImage = true}) {
final res = Delta();
final ops = delta.toList();
for (var i = 0; i < ops.length; i++) {
final op = ops[i];
res.push(op);
_handleImageInsert(i, ops, op, res);
if (autoAppendNewlineAfterImage) {
_autoAppendNewlineAfterImage(i, ops, op, res);
}
}
return res;
}
static void _handleImageInsert(
static void _autoAppendNewlineAfterImage(
int i, List<Operation> ops, Operation op, Delta res) {
final nextOpIsImage =
i + 1 < ops.length && ops[i + 1].isInsert && ops[i + 1].data is! String;
if (nextOpIsImage && !(op.data as String).endsWith('\n')) {
if (nextOpIsImage &&
op.data is String &&
(op.data as String).isNotEmpty &&
!(op.data as String).endsWith('\n'))
{
res.push(Operation.insert('\n'));
}
// Currently embed is equivalent to image and hence `is! String`

@ -28,6 +28,8 @@ abstract class Rule {
class Rules {
Rules(this._rules);
List<Rule> _customRules = [];
final List<Rule> _rules;
static final Rules _instance = Rules([
const FormatLinkAtCaretPositionRule(),
@ -49,10 +51,14 @@ class Rules {
static Rules getInstance() => _instance;
void setCustomRules(List<Rule> customRules) {
_customRules = customRules;
}
Delta apply(RuleType ruleType, Document document, int index,
{int? len, Object? data, Attribute? attribute}) {
final delta = document.toDelta();
for (final rule in _rules) {
for (final rule in _customRules + _rules) {
if (rule.type != ruleType) {
continue;
}

@ -92,12 +92,12 @@ class QuillController extends ChangeNotifier {
void replaceText(
int index, int len, Object? data, TextSelection? textSelection,
{bool ignoreFocus = false}) {
{bool ignoreFocus = false, bool autoAppendNewlineAfterImage = true}) {
assert(data is String || data is Embeddable);
Delta? delta;
if (len > 0 || data is! String || data.isNotEmpty) {
delta = document.replace(index, len, data);
delta = document.replace(index, len, data, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
var shouldRetainDelta = toggledStyle.isNotEmpty &&
delta.isNotEmpty &&
delta.length <= 2 &&

@ -1043,7 +1043,6 @@ class RenderEditableContainerBox extends RenderBox
@override
void performLayout() {
assert(!constraints.hasBoundedHeight);
assert(constraints.hasBoundedWidth);
_resolvePadding();
assert(_resolvedPadding != null);

@ -1008,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);
}
}

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

Loading…
Cancel
Save