dartlangeditorflutterflutter-appsflutter-examplesflutter-packageflutter-widgetquillquill-deltaquilljsreactquillrich-textrich-text-editorwysiwygwysiwyg-editor
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
670 lines
20 KiB
670 lines
20 KiB
import 'package:flutter/material.dart'; |
|
import 'package:flutter/rendering.dart'; |
|
|
|
import '../models/documents/attribute.dart'; |
|
import '../models/documents/nodes/block.dart'; |
|
import '../models/documents/nodes/line.dart'; |
|
import '../models/structs/vertical_spacing.dart'; |
|
import '../utils/delta.dart'; |
|
import 'box.dart'; |
|
import 'controller.dart'; |
|
import 'cursor.dart'; |
|
import 'default_styles.dart'; |
|
import 'delegate.dart'; |
|
import 'editor/editor.dart'; |
|
import 'link.dart'; |
|
import 'style_widgets/bullet_point.dart'; |
|
import 'style_widgets/checkbox_point.dart'; |
|
import 'style_widgets/number_point.dart'; |
|
import 'text_line.dart'; |
|
import 'text_selection.dart'; |
|
|
|
const List<int> arabianRomanNumbers = [ |
|
1000, |
|
900, |
|
500, |
|
400, |
|
100, |
|
90, |
|
50, |
|
40, |
|
10, |
|
9, |
|
5, |
|
4, |
|
1 |
|
]; |
|
|
|
const List<String> romanNumbers = [ |
|
'M', |
|
'CM', |
|
'D', |
|
'CD', |
|
'C', |
|
'XC', |
|
'L', |
|
'XL', |
|
'X', |
|
'IX', |
|
'V', |
|
'IV', |
|
'I' |
|
]; |
|
|
|
class EditableTextBlock extends StatelessWidget { |
|
const EditableTextBlock({ |
|
required this.block, |
|
required this.controller, |
|
required this.textDirection, |
|
required this.scrollBottomInset, |
|
required this.verticalSpacing, |
|
required this.textSelection, |
|
required this.color, |
|
required this.styles, |
|
required this.enableInteractiveSelection, |
|
required this.hasFocus, |
|
required this.contentPadding, |
|
required this.embedBuilder, |
|
required this.linkActionPicker, |
|
required this.cursorCont, |
|
required this.indentLevelCounts, |
|
required this.clearIndents, |
|
required this.onCheckboxTap, |
|
required this.readOnly, |
|
this.onLaunchUrl, |
|
this.customStyleBuilder, |
|
this.customLinkPrefixes = const <String>[], |
|
super.key, |
|
}); |
|
|
|
final Block block; |
|
final QuillController controller; |
|
final TextDirection textDirection; |
|
final double scrollBottomInset; |
|
final VerticalSpacing verticalSpacing; |
|
final TextSelection textSelection; |
|
final Color color; |
|
final DefaultStyles? styles; |
|
final bool enableInteractiveSelection; |
|
final bool hasFocus; |
|
final EdgeInsets? contentPadding; |
|
final EmbedsBuilder embedBuilder; |
|
final LinkActionPicker linkActionPicker; |
|
final ValueChanged<String>? onLaunchUrl; |
|
final CustomStyleBuilder? customStyleBuilder; |
|
final CursorCont cursorCont; |
|
final Map<int, int> indentLevelCounts; |
|
final bool clearIndents; |
|
final Function(int, bool) onCheckboxTap; |
|
final bool readOnly; |
|
final List<String> customLinkPrefixes; |
|
|
|
@override |
|
Widget build(BuildContext context) { |
|
assert(debugCheckHasMediaQuery(context)); |
|
|
|
final defaultStyles = QuillStyles.getStyles(context, false); |
|
return _EditableBlock( |
|
block: block, |
|
textDirection: textDirection, |
|
padding: verticalSpacing, |
|
scrollBottomInset: scrollBottomInset, |
|
decoration: |
|
_getDecorationForBlock(block, defaultStyles) ?? const BoxDecoration(), |
|
contentPadding: contentPadding, |
|
children: _buildChildren( |
|
context, |
|
indentLevelCounts, |
|
clearIndents, |
|
), |
|
); |
|
} |
|
|
|
BoxDecoration? _getDecorationForBlock( |
|
Block node, DefaultStyles? defaultStyles) { |
|
final attrs = block.style.attributes; |
|
if (attrs.containsKey(Attribute.blockQuote.key)) { |
|
return defaultStyles!.quote!.decoration; |
|
} |
|
if (attrs.containsKey(Attribute.codeBlock.key)) { |
|
return defaultStyles!.code!.decoration; |
|
} |
|
return null; |
|
} |
|
|
|
List<Widget> _buildChildren(BuildContext context, |
|
Map<int, int> indentLevelCounts, bool clearIndents) { |
|
final defaultStyles = QuillStyles.getStyles(context, false); |
|
final count = block.children.length; |
|
final children = <Widget>[]; |
|
if (clearIndents) { |
|
indentLevelCounts.clear(); |
|
} |
|
var index = 0; |
|
for (final line in Iterable.castFrom<dynamic, Line>(block.children)) { |
|
index++; |
|
final editableTextLine = EditableTextLine( |
|
line, |
|
_buildLeading(context, line, index, indentLevelCounts, count), |
|
TextLine( |
|
line: line, |
|
textDirection: textDirection, |
|
embedBuilder: embedBuilder, |
|
customStyleBuilder: customStyleBuilder, |
|
styles: styles!, |
|
readOnly: readOnly, |
|
controller: controller, |
|
linkActionPicker: linkActionPicker, |
|
onLaunchUrl: onLaunchUrl, |
|
customLinkPrefixes: customLinkPrefixes, |
|
), |
|
_getIndentWidth(context, count), |
|
_getSpacingForLine(line, index, count, defaultStyles), |
|
textDirection, |
|
textSelection, |
|
color, |
|
enableInteractiveSelection, |
|
hasFocus, |
|
MediaQuery.devicePixelRatioOf(context), |
|
cursorCont, |
|
); |
|
final nodeTextDirection = getDirectionOfNode(line); |
|
children.add( |
|
Directionality( |
|
textDirection: nodeTextDirection, |
|
child: editableTextLine, |
|
), |
|
); |
|
} |
|
return children.toList(growable: false); |
|
} |
|
|
|
double _numberPointWidth(double fontSize, int count) { |
|
final length = '$count'.length; |
|
switch (length) { |
|
case 1: |
|
case 2: |
|
return fontSize * 2; |
|
default: |
|
// 3 -> 2.5 |
|
// 4 -> 3 |
|
// 5 -> 3.5 |
|
return fontSize * (length - (length - 2) / 2); |
|
} |
|
} |
|
|
|
Widget? _buildLeading(BuildContext context, Line line, int index, |
|
Map<int, int> indentLevelCounts, int count) { |
|
final defaultStyles = QuillStyles.getStyles(context, false)!; |
|
final fontSize = defaultStyles.paragraph?.style.fontSize ?? 16; |
|
final attrs = line.style.attributes; |
|
|
|
if (attrs[Attribute.list.key] == Attribute.ol) { |
|
return QuillNumberPoint( |
|
index: index, |
|
indentLevelCounts: indentLevelCounts, |
|
count: count, |
|
style: defaultStyles.leading!.style, |
|
attrs: attrs, |
|
width: _numberPointWidth(fontSize, count), |
|
padding: fontSize / 2, |
|
); |
|
} |
|
|
|
if (attrs[Attribute.list.key] == Attribute.ul) { |
|
return QuillBulletPoint( |
|
style: |
|
defaultStyles.leading!.style.copyWith(fontWeight: FontWeight.bold), |
|
width: fontSize * 2, |
|
padding: fontSize / 2, |
|
); |
|
} |
|
|
|
if (attrs[Attribute.list.key] == Attribute.checked || |
|
attrs[Attribute.list.key] == Attribute.unchecked) { |
|
return CheckboxPoint( |
|
size: fontSize, |
|
value: attrs[Attribute.list.key] == Attribute.checked, |
|
enabled: !readOnly, |
|
onChanged: (checked) => onCheckboxTap(line.documentOffset, checked), |
|
uiBuilder: defaultStyles.lists?.checkboxUIBuilder, |
|
); |
|
} |
|
|
|
if (attrs.containsKey(Attribute.codeBlock.key)) { |
|
return QuillNumberPoint( |
|
index: index, |
|
indentLevelCounts: indentLevelCounts, |
|
count: count, |
|
style: defaultStyles.code!.style |
|
.copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)), |
|
width: _numberPointWidth(fontSize, count), |
|
attrs: attrs, |
|
padding: fontSize, |
|
withDot: false, |
|
); |
|
} |
|
return null; |
|
} |
|
|
|
double _getIndentWidth(BuildContext context, int count) { |
|
final defaultStyles = QuillStyles.getStyles(context, false)!; |
|
final fontSize = defaultStyles.paragraph?.style.fontSize ?? 16; |
|
final attrs = block.style.attributes; |
|
|
|
final indent = attrs[Attribute.indent.key]; |
|
var extraIndent = 0.0; |
|
if (indent != null && indent.value != null) { |
|
extraIndent = fontSize * indent.value; |
|
} |
|
|
|
if (attrs.containsKey(Attribute.blockQuote.key)) { |
|
return fontSize + extraIndent; |
|
} |
|
|
|
var baseIndent = 0.0; |
|
|
|
if (attrs.containsKey(Attribute.list.key)) { |
|
baseIndent = fontSize * 2; |
|
if (attrs[Attribute.list.key] == Attribute.ol) { |
|
baseIndent = _numberPointWidth(fontSize, count); |
|
} else if (attrs.containsKey(Attribute.codeBlock.key)) { |
|
baseIndent = _numberPointWidth(fontSize, count); |
|
} |
|
} |
|
|
|
return baseIndent + extraIndent; |
|
} |
|
|
|
VerticalSpacing _getSpacingForLine( |
|
Line node, int index, int count, DefaultStyles? defaultStyles) { |
|
var top = 0.0, bottom = 0.0; |
|
|
|
final attrs = block.style.attributes; |
|
if (attrs.containsKey(Attribute.header.key)) { |
|
final level = attrs[Attribute.header.key]!.value; |
|
switch (level) { |
|
case 1: |
|
top = defaultStyles!.h1!.verticalSpacing.top; |
|
bottom = defaultStyles.h1!.verticalSpacing.bottom; |
|
break; |
|
case 2: |
|
top = defaultStyles!.h2!.verticalSpacing.top; |
|
bottom = defaultStyles.h2!.verticalSpacing.bottom; |
|
break; |
|
case 3: |
|
top = defaultStyles!.h3!.verticalSpacing.top; |
|
bottom = defaultStyles.h3!.verticalSpacing.bottom; |
|
break; |
|
default: |
|
throw 'Invalid level $level'; |
|
} |
|
} else { |
|
late VerticalSpacing lineSpacing; |
|
if (attrs.containsKey(Attribute.blockQuote.key)) { |
|
lineSpacing = defaultStyles!.quote!.lineSpacing; |
|
} else if (attrs.containsKey(Attribute.indent.key)) { |
|
lineSpacing = defaultStyles!.indent!.lineSpacing; |
|
} else if (attrs.containsKey(Attribute.list.key)) { |
|
lineSpacing = defaultStyles!.lists!.lineSpacing; |
|
} else if (attrs.containsKey(Attribute.codeBlock.key)) { |
|
lineSpacing = defaultStyles!.code!.lineSpacing; |
|
} else if (attrs.containsKey(Attribute.align.key)) { |
|
lineSpacing = defaultStyles!.align!.lineSpacing; |
|
} else { |
|
// use paragraph linespacing as a default |
|
lineSpacing = defaultStyles!.paragraph!.lineSpacing; |
|
} |
|
top = lineSpacing.top; |
|
bottom = lineSpacing.bottom; |
|
} |
|
|
|
if (index == 1) { |
|
top = 0.0; |
|
} |
|
|
|
if (index == count) { |
|
bottom = 0.0; |
|
} |
|
|
|
return VerticalSpacing(top, bottom); |
|
} |
|
} |
|
|
|
class RenderEditableTextBlock extends RenderEditableContainerBox |
|
implements RenderEditableBox { |
|
RenderEditableTextBlock({ |
|
required Block block, |
|
required TextDirection textDirection, |
|
required EdgeInsetsGeometry padding, |
|
required double scrollBottomInset, |
|
required Decoration decoration, |
|
List<RenderEditableBox>? children, |
|
EdgeInsets contentPadding = EdgeInsets.zero, |
|
}) : _decoration = decoration, |
|
_configuration = ImageConfiguration(textDirection: textDirection), |
|
_savedPadding = padding, |
|
_contentPadding = contentPadding, |
|
super( |
|
children: children, |
|
container: block, |
|
textDirection: textDirection, |
|
scrollBottomInset: scrollBottomInset, |
|
padding: padding.add(contentPadding), |
|
); |
|
|
|
EdgeInsetsGeometry _savedPadding; |
|
EdgeInsets _contentPadding; |
|
|
|
set contentPadding(EdgeInsets value) { |
|
if (_contentPadding == value) return; |
|
_contentPadding = value; |
|
super.setPadding(_savedPadding.add(_contentPadding)); |
|
} |
|
|
|
@override |
|
void setPadding(EdgeInsetsGeometry value) { |
|
super.setPadding(value.add(_contentPadding)); |
|
_savedPadding = value; |
|
} |
|
|
|
BoxPainter? _painter; |
|
|
|
Decoration get decoration => _decoration; |
|
Decoration _decoration; |
|
|
|
set decoration(Decoration value) { |
|
if (value == _decoration) return; |
|
_painter?.dispose(); |
|
_painter = null; |
|
_decoration = value; |
|
markNeedsPaint(); |
|
} |
|
|
|
ImageConfiguration get configuration => _configuration; |
|
ImageConfiguration _configuration; |
|
|
|
set configuration(ImageConfiguration value) { |
|
if (value == _configuration) return; |
|
_configuration = value; |
|
markNeedsPaint(); |
|
} |
|
|
|
@override |
|
TextRange getLineBoundary(TextPosition position) { |
|
final child = childAtPosition(position); |
|
final rangeInChild = child.getLineBoundary(TextPosition( |
|
offset: position.offset - child.container.offset, |
|
affinity: position.affinity, |
|
)); |
|
return TextRange( |
|
start: rangeInChild.start + child.container.offset, |
|
end: rangeInChild.end + child.container.offset, |
|
); |
|
} |
|
|
|
@override |
|
Offset getOffsetForCaret(TextPosition position) { |
|
final child = childAtPosition(position); |
|
return child.getOffsetForCaret(TextPosition( |
|
offset: position.offset - child.container.offset, |
|
affinity: position.affinity, |
|
)) + |
|
(child.parentData as BoxParentData).offset; |
|
} |
|
|
|
@override |
|
TextPosition getPositionForOffset(Offset offset) { |
|
final child = childAtOffset(offset); |
|
final parentData = child.parentData as BoxParentData; |
|
final localPosition = |
|
child.getPositionForOffset(offset - parentData.offset); |
|
return TextPosition( |
|
offset: localPosition.offset + child.container.offset, |
|
affinity: localPosition.affinity, |
|
); |
|
} |
|
|
|
@override |
|
TextRange getWordBoundary(TextPosition position) { |
|
final child = childAtPosition(position); |
|
final nodeOffset = child.container.offset; |
|
final childWord = child |
|
.getWordBoundary(TextPosition(offset: position.offset - nodeOffset)); |
|
return TextRange( |
|
start: childWord.start + nodeOffset, |
|
end: childWord.end + nodeOffset, |
|
); |
|
} |
|
|
|
@override |
|
TextPosition? getPositionAbove(TextPosition position) { |
|
assert(position.offset < container.length); |
|
|
|
final child = childAtPosition(position); |
|
final childLocalPosition = |
|
TextPosition(offset: position.offset - child.container.offset); |
|
final result = child.getPositionAbove(childLocalPosition); |
|
if (result != null) { |
|
return TextPosition(offset: result.offset + child.container.offset); |
|
} |
|
|
|
final sibling = childBefore(child); |
|
if (sibling == null) { |
|
return null; |
|
} |
|
|
|
final caretOffset = child.getOffsetForCaret(childLocalPosition); |
|
final testPosition = TextPosition(offset: sibling.container.length - 1); |
|
final testOffset = sibling.getOffsetForCaret(testPosition); |
|
final finalOffset = Offset(caretOffset.dx, testOffset.dy); |
|
return TextPosition( |
|
offset: sibling.container.offset + |
|
sibling.getPositionForOffset(finalOffset).offset); |
|
} |
|
|
|
@override |
|
TextPosition? getPositionBelow(TextPosition position) { |
|
assert(position.offset < container.length); |
|
|
|
final child = childAtPosition(position); |
|
final childLocalPosition = |
|
TextPosition(offset: position.offset - child.container.offset); |
|
final result = child.getPositionBelow(childLocalPosition); |
|
if (result != null) { |
|
return TextPosition(offset: result.offset + child.container.offset); |
|
} |
|
|
|
final sibling = childAfter(child); |
|
if (sibling == null) { |
|
return null; |
|
} |
|
|
|
final caretOffset = child.getOffsetForCaret(childLocalPosition); |
|
final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0)); |
|
final finalOffset = Offset(caretOffset.dx, testOffset.dy); |
|
return TextPosition( |
|
offset: sibling.container.offset + |
|
sibling.getPositionForOffset(finalOffset).offset); |
|
} |
|
|
|
@override |
|
double preferredLineHeight(TextPosition position) { |
|
final child = childAtPosition(position); |
|
return child.preferredLineHeight( |
|
TextPosition(offset: position.offset - child.container.offset)); |
|
} |
|
|
|
@override |
|
TextSelectionPoint getBaseEndpointForSelection(TextSelection selection) { |
|
if (selection.isCollapsed) { |
|
return TextSelectionPoint( |
|
Offset(0, preferredLineHeight(selection.extent)) + |
|
getOffsetForCaret(selection.extent), |
|
null); |
|
} |
|
|
|
final baseNode = container.queryChild(selection.start, false).node; |
|
var baseChild = firstChild; |
|
while (baseChild != null) { |
|
if (baseChild.container == baseNode) { |
|
break; |
|
} |
|
baseChild = childAfter(baseChild); |
|
} |
|
assert(baseChild != null); |
|
|
|
final basePoint = baseChild!.getBaseEndpointForSelection( |
|
localSelection(baseChild.container, selection, true)); |
|
return TextSelectionPoint( |
|
basePoint.point + (baseChild.parentData as BoxParentData).offset, |
|
basePoint.direction); |
|
} |
|
|
|
@override |
|
TextSelectionPoint getExtentEndpointForSelection(TextSelection selection) { |
|
if (selection.isCollapsed) { |
|
return TextSelectionPoint( |
|
Offset(0, preferredLineHeight(selection.extent)) + |
|
getOffsetForCaret(selection.extent), |
|
null); |
|
} |
|
|
|
final extentNode = container.queryChild(selection.end, false).node; |
|
|
|
var extentChild = firstChild; |
|
while (extentChild != null) { |
|
if (extentChild.container == extentNode) { |
|
break; |
|
} |
|
extentChild = childAfter(extentChild); |
|
} |
|
assert(extentChild != null); |
|
|
|
final extentPoint = extentChild!.getExtentEndpointForSelection( |
|
localSelection(extentChild.container, selection, true)); |
|
return TextSelectionPoint( |
|
extentPoint.point + (extentChild.parentData as BoxParentData).offset, |
|
extentPoint.direction); |
|
} |
|
|
|
@override |
|
void detach() { |
|
_painter?.dispose(); |
|
_painter = null; |
|
super.detach(); |
|
markNeedsPaint(); |
|
} |
|
|
|
@override |
|
void paint(PaintingContext context, Offset offset) { |
|
_paintDecoration(context, offset); |
|
defaultPaint(context, offset); |
|
} |
|
|
|
void _paintDecoration(PaintingContext context, Offset offset) { |
|
_painter ??= _decoration.createBoxPainter(markNeedsPaint); |
|
|
|
final decorationPadding = resolvedPadding! - _contentPadding; |
|
|
|
final filledConfiguration = |
|
configuration.copyWith(size: decorationPadding.deflateSize(size)); |
|
final debugSaveCount = context.canvas.getSaveCount(); |
|
|
|
final decorationOffset = |
|
offset.translate(decorationPadding.left, decorationPadding.top); |
|
_painter!.paint(context.canvas, decorationOffset, filledConfiguration); |
|
if (debugSaveCount != context.canvas.getSaveCount()) { |
|
throw '${_decoration.runtimeType} painter had mismatching save and ' |
|
'restore calls.'; |
|
} |
|
if (decoration.isComplex) { |
|
context.setIsComplexHint(); |
|
} |
|
} |
|
|
|
@override |
|
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { |
|
return defaultHitTestChildren(result, position: position); |
|
} |
|
|
|
@override |
|
Rect getLocalRectForCaret(TextPosition position) { |
|
final child = childAtPosition(position); |
|
final localPosition = TextPosition( |
|
offset: position.offset - child.container.offset, |
|
affinity: position.affinity, |
|
); |
|
final parentData = child.parentData as BoxParentData; |
|
return child.getLocalRectForCaret(localPosition).shift(parentData.offset); |
|
} |
|
|
|
@override |
|
TextPosition globalToLocalPosition(TextPosition position) { |
|
assert(container.containsOffset(position.offset), |
|
'The provided text position is not in the current node'); |
|
return TextPosition( |
|
offset: position.offset - container.documentOffset, |
|
affinity: position.affinity, |
|
); |
|
} |
|
|
|
@override |
|
Rect getCaretPrototype(TextPosition position) { |
|
final child = childAtPosition(position); |
|
final localPosition = TextPosition( |
|
offset: position.offset - child.container.offset, |
|
affinity: position.affinity, |
|
); |
|
return child.getCaretPrototype(localPosition); |
|
} |
|
} |
|
|
|
class _EditableBlock extends MultiChildRenderObjectWidget { |
|
const _EditableBlock( |
|
{required this.block, |
|
required this.textDirection, |
|
required this.padding, |
|
required this.scrollBottomInset, |
|
required this.decoration, |
|
required this.contentPadding, |
|
required List<Widget> children, |
|
Key? key}) |
|
: super(key: key, children: children); |
|
|
|
final Block block; |
|
final TextDirection textDirection; |
|
final VerticalSpacing padding; |
|
final double scrollBottomInset; |
|
final Decoration decoration; |
|
final EdgeInsets? contentPadding; |
|
|
|
EdgeInsets get _padding => |
|
EdgeInsets.only(top: padding.top, bottom: padding.bottom); |
|
|
|
EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero; |
|
|
|
@override |
|
RenderEditableTextBlock createRenderObject(BuildContext context) { |
|
return RenderEditableTextBlock( |
|
block: block, |
|
textDirection: textDirection, |
|
padding: _padding, |
|
scrollBottomInset: scrollBottomInset, |
|
decoration: decoration, |
|
contentPadding: _contentPadding, |
|
); |
|
} |
|
|
|
@override |
|
void updateRenderObject( |
|
BuildContext context, covariant RenderEditableTextBlock renderObject) { |
|
renderObject |
|
..setContainer(block) |
|
..textDirection = textDirection |
|
..scrollBottomInset = scrollBottomInset |
|
..setPadding(_padding) |
|
..decoration = decoration |
|
..contentPadding = _contentPadding; |
|
} |
|
}
|
|
|