From 06c6f911c9811cd1b50d97a48501bf6852424e6a Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Sun, 18 May 2025 22:51:00 -0700 Subject: [PATCH 1/2] [SuperEditor][SuperReader] - Custom underline style configuration (Resolves #2675) --- .../source/super-editor/guides/_data.yaml | 2 + .../guides/styling/text-underlines.md | 167 ++++++++++++++++++ .../in_the_lab/feature_custom_underlines.dart | 129 ++++++++++++++ super_editor/example/lib/main.dart | 8 + super_editor/lib/src/core/styles.dart | 7 + .../lib/src/default_editor/attributions.dart | 70 +++++++- .../lib/src/default_editor/blockquote.dart | 82 +++------ .../attributed_text_bounds_overlay.dart | 3 + .../lib/src/default_editor/list_items.dart | 123 ++++++------- .../lib/src/default_editor/paragraph.dart | 139 ++++++--------- .../lib/src/default_editor/super_editor.dart | 2 + .../lib/src/default_editor/tasks.dart | 78 +++----- super_editor/lib/src/default_editor/text.dart | 130 ++++++++++++-- .../text/custom_underlines.dart | 102 +++++++++++ .../attribution_layout_bounds.dart | 11 +- super_editor/lib/super_editor.dart | 1 + super_editor/pubspec.yaml | 6 +- .../lib/src/text_underline_layer.dart | 62 ++++++- 18 files changed, 821 insertions(+), 301 deletions(-) create mode 100644 doc/website/source/super-editor/guides/styling/text-underlines.md create mode 100644 super_editor/example/lib/demos/in_the_lab/feature_custom_underlines.dart create mode 100644 super_editor/lib/src/default_editor/text/custom_underlines.dart diff --git a/doc/website/source/super-editor/guides/_data.yaml b/doc/website/source/super-editor/guides/_data.yaml index 6cfdce619d..2de4ab3696 100644 --- a/doc/website/source/super-editor/guides/_data.yaml +++ b/doc/website/source/super-editor/guides/_data.yaml @@ -82,6 +82,8 @@ navigation: url: super-editor/guides/styling/dark-mode-and-light-mode - title: Style a Document url: super-editor/guides/styling/style-a-document + - title: Text Underlines + url: super-editor/guides/styling/text-underlines - title: Editing UI items: diff --git a/doc/website/source/super-editor/guides/styling/text-underlines.md b/doc/website/source/super-editor/guides/styling/text-underlines.md new file mode 100644 index 0000000000..186e86f8dd --- /dev/null +++ b/doc/website/source/super-editor/guides/styling/text-underlines.md @@ -0,0 +1,167 @@ +--- +title: Text Underlines +--- +Underlines in Flutter text don't support any styles. They're always the same +thickness, the same distance from the text, the same color as the text, and +have the same square end-caps. It should be possible to control these styles, +but Flutter doesn't expose the lower level text layout controls. + +Editors require custom underline painting for styles and design languages that +don't exactly match the standard text underline. Super Editor supports custom +painting of underlines by manually positioning the painted lines beneath the +relevant spans of text. + +## Special Underlines +Super Editor treats some underlines as special. These include: + + * The user's composing region. + * Spelling errors. + * Grammar errors. + +For these special underlines, please see other guides and references to +work with them. + +## Custom Underlines +Super Editor supports painting custom text underlines. + +### Attribute the Text +First, attribute the desired text with a `CustomUnderlineAttribution`, which +specifies the visual type of underline. Super Editor includes some pre-defined +type names, but you can use any name. + +```dart +final underlineAttribution = CustomUnderlineAttribution( + CustomUnderlineAttribution.standard, +); + +AttributedText( + "This text includes an underline.", + AttributedSpans( + attributions: [ + SpanMarker(attribution: underlineAttribution, offset: 22, markerType: SpanMarkerType.start), + SpanMarker(attribution: underlineAttribution, offset: 30, markerType: SpanMarkerType.end), + ], + ), +) +``` + +### Style the Underlines +Add a style rule to your stylesheet, which specifies all underline styles. + +```dart +final myStylesheet = defaultStylesheet.copyWith( + addRulesBefore: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + // The `underlineStyles` key is used to identify a collection of + // underline styles. + // + // Within the `CustomUnderlineStyles`, you should add an entry + // for every underline type name that your app uses, and then + // specify the `UnderlineStyle` to paint that underline. + UnderlineStyler.underlineStyles: CustomUnderlineStyles({ + // In this example, we specify only one underline style. This + // style is for the `standard` underline type, and it paints + // a green squiggly underline. + CustomUnderlineAttribution.standard: SquiggleUnderlineStyle( + color: Colors.green, + ), + // You can add more types and styles here... + }), + }; + }, + ), + ], +); +``` + +### Custom Styles +Super Editor provides a few underline styles, which offer some configuration, +including `StraightUnderlineStyle`, `DottedUnderlineStyle`, and `SquiggleUnderlineStyle`. +However, these may not meet your needs. + +To paint your own underline, you need to create two classes: a subclass of `UnderlineStyle` +and a `CustomPainter` that actually does the painting. + +The `UnderlineStyle` subclass is like a view-model, and the `CustomPainter` uses +properties from the `UnderlineStyle` to decide how to paint the underline. + +For example, the following is the implementation of `StraightUnderlineStyle`. + +```dart +class StraightUnderlineStyle implements UnderlineStyle { + const StraightUnderlineStyle({ + this.color = const Color(0xFF000000), + this.thickness = 2, + this.capType = StrokeCap.square, + }); + + final Color color; + final double thickness; + final StrokeCap capType; + + @override + CustomPainter createPainter(List underlines) { + return StraightUnderlinePainter(underlines: underlines, color: color, thickness: thickness, capType: capType); + } +} +``` + +The job of the `UnderlineStyle` is to take a collection of properties and +pass them in some form to a `CustomPainter`. In the case of `StraightUnderlineStyle`, +the properties are passed to a `StraightUnderlinePainter`. The `createPainter()` +method is called by Super Editor at the appropriate time. + +To complete the example, the following is the implementation of `StraightUnderlinePainter`. + +```dart +class StraightUnderlinePainter extends CustomPainter { + const StraightUnderlinePainter({ + required List underlines, + this.color = const Color(0xFF000000), + this.thickness = 2, + this.capType = StrokeCap.square, + }) : _underlines = underlines; + + final List _underlines; + + final Color color; + final double thickness; + final StrokeCap capType; + + @override + void paint(Canvas canvas, Size size) { + if (_underlines.isEmpty) { + return; + } + + final linePaint = Paint() + ..style = PaintingStyle.stroke + ..color = color + ..strokeWidth = thickness + ..strokeCap = capType; + for (final underline in _underlines) { + canvas.drawLine(underline.start, underline.end, linePaint); + } + } + + @override + bool shouldRepaint(StraightUnderlinePainter oldDelegate) { + return color != oldDelegate.color || + thickness != oldDelegate.thickness || + capType != oldDelegate.capType || + !const DeepCollectionEquality().equals(_underlines, oldDelegate._underlines); + } +} +``` + +By providing your own version of these two classes, you can paint any underline you desire. + +With your own `UnderlineStyle` defined, use it in your stylesheet as discussed previously. + +As you implement your own underline painting, you might be confused where some of these +underline classes come from. Note that some of them are lower level than Super Editor - +they come from the `super_text_layout` package, which is another package in the +Super Editor mono repo. \ No newline at end of file diff --git a/super_editor/example/lib/demos/in_the_lab/feature_custom_underlines.dart b/super_editor/example/lib/demos/in_the_lab/feature_custom_underlines.dart new file mode 100644 index 0000000000..8a04453cc3 --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_custom_underlines.dart @@ -0,0 +1,129 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class CustomUnderlinesDemo extends StatefulWidget { + const CustomUnderlinesDemo({super.key}); + + @override + State createState() => _CustomUnderlinesDemoState(); +} + +class _CustomUnderlinesDemoState extends State { + late final Editor _editor; + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: _createDocument(), + composer: MutableDocumentComposer(), + ); + } + + @override + Widget build(BuildContext context) { + return InTheLabScaffold( + content: SuperEditor( + editor: _editor, + stylesheet: defaultStylesheet.copyWith( + addRulesBefore: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.customUnderlineStyles: CustomUnderlineStyles({ + _brandUnderline: StraightUnderlineStyle( + color: Colors.red, + thickness: 3, + capType: StrokeCap.round, + offset: -3, + ), + _dottedUnderline: DottedUnderlineStyle( + color: Colors.blue, + ), + _squiggleUnderline: SquiggleUnderlineStyle( + color: Colors.green, + ), + }), + }; + }, + ), + ], + addRulesAfter: [ + ...darkModeStyles, + ], + selectedTextColorStrategy: ({ + required Color originalTextColor, + required Color selectionHighlightColor, + }) => + Colors.black, + ), + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + ), + ); + } +} + +MutableDocument _createDocument() { + return MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("Custom Underlines"), + metadata: { + NodeMetadata.blockType: header1Attribution, + }, + ), + ParagraphNode( + id: "2", + text: AttributedText( + "Super Editor supports custom painted underlines across text spans.", + AttributedSpans( + attributions: [ + SpanMarker( + attribution: CustomUnderlineAttribution(_brandUnderline), + offset: 0, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: CustomUnderlineAttribution(_brandUnderline), + offset: 11, + markerType: SpanMarkerType.end, + ), + SpanMarker( + attribution: CustomUnderlineAttribution(_dottedUnderline), + offset: 22, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: CustomUnderlineAttribution(_dottedUnderline), + offset: 35, + markerType: SpanMarkerType.end, + ), + SpanMarker( + attribution: CustomUnderlineAttribution(_squiggleUnderline), + offset: 48, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: CustomUnderlineAttribution(_squiggleUnderline), + offset: 64, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ), + ], + ); +} + +const _brandUnderline = "brand"; +const _dottedUnderline = "dotted"; +const _squiggleUnderline = "squiggly"; diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index 8b0a94dd58..e0263264a8 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -15,6 +15,7 @@ import 'package:example/demos/flutter_features/demo_inline_widgets.dart'; import 'package:example/demos/flutter_features/textinputclient/basic_text_input_client.dart'; import 'package:example/demos/flutter_features/textinputclient/textfield.dart'; import 'package:example/demos/in_the_lab/feature_action_tags.dart'; +import 'package:example/demos/in_the_lab/feature_custom_underlines.dart'; import 'package:example/demos/in_the_lab/feature_ios_native_context_menu.dart'; import 'package:example/demos/in_the_lab/feature_pattern_tags.dart'; import 'package:example/demos/in_the_lab/feature_stable_tags.dart'; @@ -333,6 +334,13 @@ final _menu = <_MenuGroup>[ return const NativeIosContextMenuFeatureDemo(); }, ), + _MenuItem( + icon: Icons.line_style, + title: 'Custom Underlines', + pageBuilder: (context) { + return const CustomUnderlinesDemo(); + }, + ), ], ), _MenuGroup( diff --git a/super_editor/lib/src/core/styles.dart b/super_editor/lib/src/core/styles.dart index d11b63079d..5e27829f9f 100644 --- a/super_editor/lib/src/core/styles.dart +++ b/super_editor/lib/src/core/styles.dart @@ -1,5 +1,6 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/painting.dart'; +import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'document.dart'; @@ -347,6 +348,12 @@ class Styles { /// Applies a [TextAlign] to a text node. static const String textAlign = 'textAlign'; + /// Defines the visual style for all custom underlines rendered by all + /// text that matches the style rule selector. + /// + /// The value should be a [CustomUnderlineStyles]. + static const String customUnderlineStyles = "customUnderlineStyles"; + /// Applies an [UnderlineStyle] to the composing region, e.g., the word /// the user is currently editing on mobile. static const String composingRegionUnderlineStyle = 'composingRegionUnderlineStyle'; diff --git a/super_editor/lib/src/default_editor/attributions.dart b/super_editor/lib/src/default_editor/attributions.dart index 8acd736dbd..c13ea15a8b 100644 --- a/super_editor/lib/src/default_editor/attributions.dart +++ b/super_editor/lib/src/default_editor/attributions.dart @@ -106,7 +106,7 @@ class ColorAttribution implements Attribution { } /// Attribution to be used within [AttributedText] to -/// represent an inline span of a backgrounnd color change. +/// represent an inline span of a background color change. /// /// Every [BackgroundColorAttribution] is considered equivalent so /// that [AttributedText] prevents multiple [BackgroundColorAttribution]s @@ -138,6 +138,74 @@ class BackgroundColorAttribution implements Attribution { } } +/// Attribution to be used within [AttributedText] to mark text that should be painted +/// with a custom underline. +/// +/// A custom underline is an underline that's painted by Super Editor, rather than +/// painted by the text layout package, inside of the Flutter engine. Flutter's standard +/// text underline doesn't allow for any stylistic configuration. It always has the +/// same thickness, the same end-caps, sits the same distance from the text, and has +/// the same color as the text. This is insufficient for real world document editing +/// use-cases. +/// +/// A [CustomUnderlineAttribution] tells Super Editor that a user wants to paint a +/// custom underline beneath a span of text. From there, various pieces of the Super Editor +/// styling system process the attribution, and paint the desired underline. +/// +/// ## Other Approaches to Underlines +/// [CustomUnderlineAttribution]s refer to visual style choices, similar to bold, italics, +/// and strikethrough. In other words, this attribution is for painting underlines in situations +/// where the spans of text don't represent some other semantic meaning. +/// +/// Super Editor includes other underlined content that does include semantic meaning. +/// Therefore, those underlines don't use [CustomUnderlineAttribution]s. +/// +/// One example is the user's composing region. Super Editor underlines the composing region, +/// but that region doesn't have a [CustomUnderlineAttribution] applied to it. Instead, +/// Super Editor explicitly tracks the user's composing region in a variable. +/// +/// Another example is spelling and grammar errors. These, too, display underlines. +/// However, the placement of spelling and grammar error spans is managed by the +/// spelling and grammar check system. These spans don't simply represent a stylistic +/// underline, they carry semantic meaning. In this case that meaning is a misspelled +/// word, or a grammatically incorrect structure. +/// +/// [CustomUnderlineAttribution] is provided for situations where the underline doesn't +/// mean anything more than an underline. +class CustomUnderlineAttribution implements Attribution { + static const standard = "standard"; + + const CustomUnderlineAttribution([this.type = standard]); + + @override + String get id => 'custom_underline'; + + /// The type of underline that should be applied to the attributed text. + /// + /// The type can be anything. The meaning of the term is enforced by the developer's + /// styling system. Super Editor ships with some pre-defined terms for obvious + /// use-cases, e.g., [standard]. + final String type; + + @override + bool canMergeWith(Attribution other) { + return this == other; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CustomUnderlineAttribution && runtimeType == other.runtimeType && type == other.type; + + @override + int get hashCode => type.hashCode; + + @override + String toString() { + return '[CustomUnderlineAttribution]: $type'; + } +} + /// Attribution to be used within [AttributedText] to /// represent an inline span of a font size change. /// diff --git a/super_editor/lib/src/default_editor/blockquote.dart b/super_editor/lib/src/default_editor/blockquote.dart index 7a176f2795..7ca599c0cf 100644 --- a/super_editor/lib/src/default_editor/blockquote.dart +++ b/super_editor/lib/src/default_editor/blockquote.dart @@ -1,15 +1,11 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:super_editor/src/core/edit_context.dart'; -import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/core/styles.dart'; import 'package:super_editor/src/default_editor/attributions.dart'; import 'package:super_editor/src/default_editor/blocks/indentation.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'package:super_editor/src/infrastructure/keyboard.dart'; import 'package:super_text_layout/super_text_layout.dart'; import '../core/document.dart'; @@ -155,73 +151,45 @@ class BlockquoteComponentViewModel extends SingleColumnLayoutComponentViewModel @override BlockquoteComponentViewModel copy() { - return BlockquoteComponentViewModel( - nodeId: nodeId, - maxWidth: maxWidth, - padding: padding, - text: text, - textStyleBuilder: textStyleBuilder, - inlineWidgetBuilders: inlineWidgetBuilders, - textDirection: textDirection, - textAlignment: textAlignment, - indent: indent, - indentCalculator: indentCalculator, - backgroundColor: backgroundColor, - borderRadius: borderRadius, - selection: selection, - selectionColor: selectionColor, - highlightWhenEmpty: highlightWhenEmpty, - spellingErrorUnderlineStyle: spellingErrorUnderlineStyle, - spellingErrors: List.from(spellingErrors), - grammarErrorUnderlineStyle: grammarErrorUnderlineStyle, - grammarErrors: List.from(grammarErrors), - composingRegion: composingRegion, - showComposingRegionUnderline: showComposingRegionUnderline, + return internalCopy( + BlockquoteComponentViewModel( + nodeId: nodeId, + text: text, + textStyleBuilder: textStyleBuilder, + selectionColor: selectionColor, + backgroundColor: backgroundColor, + borderRadius: borderRadius, + ), ); } + @override + BlockquoteComponentViewModel internalCopy(BlockquoteComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as BlockquoteComponentViewModel; + + copy + ..indent = indent + ..indentCalculator = indentCalculator + ..backgroundColor = backgroundColor + ..borderRadius = borderRadius; + + return copy; + } + @override bool operator ==(Object other) => identical(this, other) || super == other && other is BlockquoteComponentViewModel && runtimeType == other.runtimeType && - nodeId == other.nodeId && - text == other.text && - textDirection == other.textDirection && - textAlignment == other.textAlignment && + textViewModelEquals(other) && indent == other.indent && backgroundColor == other.backgroundColor && - borderRadius == other.borderRadius && - selection == other.selection && - selectionColor == other.selectionColor && - highlightWhenEmpty == other.highlightWhenEmpty && - spellingErrorUnderlineStyle == other.spellingErrorUnderlineStyle && - const DeepCollectionEquality().equals(spellingErrors, other.spellingErrors) && - grammarErrorUnderlineStyle == other.grammarErrorUnderlineStyle && - const DeepCollectionEquality().equals(grammarErrors, other.grammarErrors) && - composingRegion == other.composingRegion && - showComposingRegionUnderline == other.showComposingRegionUnderline; + borderRadius == other.borderRadius; @override int get hashCode => - super.hashCode ^ - nodeId.hashCode ^ - text.hashCode ^ - textDirection.hashCode ^ - textAlignment.hashCode ^ - indent.hashCode ^ - backgroundColor.hashCode ^ - borderRadius.hashCode ^ - selection.hashCode ^ - selectionColor.hashCode ^ - highlightWhenEmpty.hashCode ^ - spellingErrorUnderlineStyle.hashCode ^ - spellingErrors.hashCode ^ - grammarErrorUnderlineStyle.hashCode ^ - grammarErrors.hashCode ^ - composingRegion.hashCode ^ - showComposingRegionUnderline.hashCode; + super.hashCode ^ textViewModelHashCode ^ indent.hashCode ^ backgroundColor.hashCode ^ borderRadius.hashCode; } /// Displays a blockquote in a document. diff --git a/super_editor/lib/src/default_editor/document_layers/attributed_text_bounds_overlay.dart b/super_editor/lib/src/default_editor/document_layers/attributed_text_bounds_overlay.dart index 2258adeeea..e86928f8d4 100644 --- a/super_editor/lib/src/default_editor/document_layers/attributed_text_bounds_overlay.dart +++ b/super_editor/lib/src/default_editor/document_layers/attributed_text_bounds_overlay.dart @@ -4,6 +4,9 @@ import 'package:super_editor/src/default_editor/super_editor.dart'; import 'package:super_editor/src/infrastructure/attribution_layout_bounds.dart'; import 'package:super_editor/src/infrastructure/content_layers.dart'; +/// A [SuperEditorLayerBuilder] that makes [AttributionBounds] usable by a `SuperEditor`. +/// +/// See [AttributionBounds] for the real implementation. class AttributedTextBoundsOverlay implements SuperEditorLayerBuilder { const AttributedTextBoundsOverlay({ required this.selector, diff --git a/super_editor/lib/src/default_editor/list_items.dart b/super_editor/lib/src/default_editor/list_items.dart index 7151b4b36a..4e4a6368b7 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -1,5 +1,4 @@ import 'package:attributed_text/attributed_text.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document_composer.dart'; @@ -285,44 +284,26 @@ abstract class ListItemComponentViewModel extends SingleColumnLayoutComponentVie @override bool highlightWhenEmpty; + @override + ListItemComponentViewModel internalCopy(ListItemComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as ListItemComponentViewModel; + + copy.indent = indent; + + return copy; + } + @override bool operator ==(Object other) => identical(this, other) || super == other && other is ListItemComponentViewModel && runtimeType == other.runtimeType && - nodeId == other.nodeId && - indent == other.indent && - text == other.text && - textDirection == other.textDirection && - textAlignment == other.textAlignment && - selection == other.selection && - selectionColor == other.selectionColor && - highlightWhenEmpty == other.highlightWhenEmpty && - spellingErrorUnderlineStyle == other.spellingErrorUnderlineStyle && - const DeepCollectionEquality().equals(spellingErrors, spellingErrors) && - grammarErrorUnderlineStyle == other.grammarErrorUnderlineStyle && - const DeepCollectionEquality().equals(grammarErrors, grammarErrors) && - composingRegion == other.composingRegion && - showComposingRegionUnderline == other.showComposingRegionUnderline; + textViewModelEquals(other) && + indent == other.indent; @override - int get hashCode => - super.hashCode ^ - nodeId.hashCode ^ - indent.hashCode ^ - text.hashCode ^ - textDirection.hashCode ^ - textAlignment.hashCode ^ - selection.hashCode ^ - selectionColor.hashCode ^ - highlightWhenEmpty.hashCode ^ - spellingErrorUnderlineStyle.hashCode ^ - spellingErrors.hashCode ^ - grammarErrorUnderlineStyle.hashCode ^ - grammarErrors.hashCode ^ - composingRegion.hashCode ^ - showComposingRegionUnderline.hashCode; + int get hashCode => super.hashCode ^ textViewModelHashCode ^ indent.hashCode; } class UnorderedListItemComponentViewModel extends ListItemComponentViewModel { @@ -362,28 +343,28 @@ class UnorderedListItemComponentViewModel extends ListItemComponentViewModel { @override UnorderedListItemComponentViewModel copy() { - return UnorderedListItemComponentViewModel( - nodeId: nodeId, - maxWidth: maxWidth, - padding: padding, - indent: indent, - text: text, - textStyleBuilder: textStyleBuilder, - dotStyle: dotStyle, - textDirection: textDirection, - textAlignment: textAlignment, - selection: selection, - selectionColor: selectionColor, - composingRegion: composingRegion, - showComposingRegionUnderline: showComposingRegionUnderline, - spellingErrorUnderlineStyle: spellingErrorUnderlineStyle, - spellingErrors: List.from(spellingErrors), - grammarErrorUnderlineStyle: grammarErrorUnderlineStyle, - grammarErrors: List.from(grammarErrors), - inlineWidgetBuilders: inlineWidgetBuilders, + return internalCopy( + UnorderedListItemComponentViewModel( + nodeId: nodeId, + text: text, + textStyleBuilder: textStyleBuilder, + selectionColor: selectionColor, + indent: indent, + ), ); } + @override + UnorderedListItemComponentViewModel internalCopy(UnorderedListItemComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as UnorderedListItemComponentViewModel; + + copy + ..indent = indent + ..dotStyle = dotStyle.copyWith(); + + return copy; + } + @override bool operator ==(Object other) => identical(this, other) || @@ -420,7 +401,7 @@ class OrderedListItemComponentViewModel extends ListItemComponentViewModel { super.grammarErrors, }); - final int? ordinalValue; + int? ordinalValue; OrderedListNumeralStyle numeralStyle; @override @@ -431,29 +412,29 @@ class OrderedListItemComponentViewModel extends ListItemComponentViewModel { @override OrderedListItemComponentViewModel copy() { - return OrderedListItemComponentViewModel( - nodeId: nodeId, - maxWidth: maxWidth, - padding: padding, - indent: indent, - ordinalValue: ordinalValue, - numeralStyle: numeralStyle, - text: text, - textStyleBuilder: textStyleBuilder, - textDirection: textDirection, - textAlignment: textAlignment, - selection: selection, - selectionColor: selectionColor, - composingRegion: composingRegion, - showComposingRegionUnderline: showComposingRegionUnderline, - spellingErrorUnderlineStyle: spellingErrorUnderlineStyle, - spellingErrors: List.from(spellingErrors), - grammarErrorUnderlineStyle: grammarErrorUnderlineStyle, - grammarErrors: List.from(grammarErrors), - inlineWidgetBuilders: inlineWidgetBuilders, + return internalCopy( + OrderedListItemComponentViewModel( + nodeId: nodeId, + text: text, + textStyleBuilder: textStyleBuilder, + selectionColor: selectionColor, + indent: indent, + ), ); } + @override + OrderedListItemComponentViewModel internalCopy(OrderedListItemComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as OrderedListItemComponentViewModel; + + copy + ..indent = indent + ..ordinalValue = ordinalValue + ..numeralStyle = numeralStyle; + + return copy; + } + @override bool operator ==(Object other) => identical(this, other) || diff --git a/super_editor/lib/src/default_editor/paragraph.dart b/super_editor/lib/src/default_editor/paragraph.dart index 9b22f39a0d..8aa98823f1 100644 --- a/super_editor/lib/src/default_editor/paragraph.dart +++ b/super_editor/lib/src/default_editor/paragraph.dart @@ -1,8 +1,6 @@ import 'package:attributed_text/attributed_text.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; @@ -14,6 +12,7 @@ import 'package:super_editor/src/default_editor/blocks/indentation.dart'; import 'package:super_editor/src/default_editor/box_component.dart'; import 'package:super_editor/src/default_editor/multi_node_editing.dart'; import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/composable_text.dart'; @@ -183,6 +182,7 @@ class ParagraphComponentViewModel extends SingleColumnLayoutComponentViewModel w this.selection, required this.selectionColor, this.highlightWhenEmpty = false, + Set customUnderlines = const {}, TextRange? composingRegion, bool showComposingRegionUnderline = false, UnderlineStyle spellingErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.red), @@ -190,6 +190,8 @@ class ParagraphComponentViewModel extends SingleColumnLayoutComponentViewModel w UnderlineStyle grammarErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.blue), List grammarErrors = const [], }) : super(nodeId: nodeId, maxWidth: maxWidth, padding: padding) { + this.customUnderlines = customUnderlines; + this.composingRegion = composingRegion; this.showComposingRegionUnderline = showComposingRegionUnderline; @@ -230,73 +232,43 @@ class ParagraphComponentViewModel extends SingleColumnLayoutComponentViewModel w @override ParagraphComponentViewModel copy() { - return ParagraphComponentViewModel( - nodeId: nodeId, - maxWidth: maxWidth, - padding: padding, - blockType: blockType, - indent: indent, - indentCalculator: indentCalculator, - text: text, - textStyleBuilder: textStyleBuilder, - inlineWidgetBuilders: inlineWidgetBuilders, - textDirection: textDirection, - textAlignment: textAlignment, - textScaler: textScaler, - selection: selection, - selectionColor: selectionColor, - highlightWhenEmpty: highlightWhenEmpty, - spellingErrorUnderlineStyle: spellingErrorUnderlineStyle, - spellingErrors: List.from(spellingErrors), - grammarErrorUnderlineStyle: grammarErrorUnderlineStyle, - grammarErrors: List.from(grammarErrors), - composingRegion: composingRegion, - showComposingRegionUnderline: showComposingRegionUnderline, + return internalCopy( + ParagraphComponentViewModel( + nodeId: nodeId, + text: text, + textStyleBuilder: textStyleBuilder, + selectionColor: selectionColor, + ), ); } + @override + ParagraphComponentViewModel internalCopy(ParagraphComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as ParagraphComponentViewModel; + + copy + ..blockType = blockType + ..indent = indent + ..indentCalculator = indentCalculator + ..textScaler = textScaler; + + return copy; + } + @override bool operator ==(Object other) => identical(this, other) || super == other && other is ParagraphComponentViewModel && runtimeType == other.runtimeType && - nodeId == other.nodeId && + textViewModelEquals(other) && blockType == other.blockType && indent == other.indent && - text == other.text && - textDirection == other.textDirection && - textAlignment == other.textAlignment && - textScaler == other.textScaler && - selection == other.selection && - selectionColor == other.selectionColor && - highlightWhenEmpty == other.highlightWhenEmpty && - spellingErrorUnderlineStyle == other.spellingErrorUnderlineStyle && - const DeepCollectionEquality().equals(spellingErrors, other.spellingErrors) && - grammarErrorUnderlineStyle == other.grammarErrorUnderlineStyle && - const DeepCollectionEquality().equals(grammarErrors, other.grammarErrors) && - composingRegion == other.composingRegion && - showComposingRegionUnderline == other.showComposingRegionUnderline; + textScaler == other.textScaler; @override int get hashCode => - super.hashCode ^ - nodeId.hashCode ^ - blockType.hashCode ^ - indent.hashCode ^ - text.hashCode ^ - textDirection.hashCode ^ - textAlignment.hashCode ^ - textScaler.hashCode ^ - selection.hashCode ^ - selectionColor.hashCode ^ - highlightWhenEmpty.hashCode ^ - spellingErrorUnderlineStyle.hashCode ^ - spellingErrors.hashCode ^ - grammarErrorUnderlineStyle.hashCode ^ - grammarErrors.hashCode ^ - composingRegion.hashCode ^ - showComposingRegionUnderline.hashCode; + super.hashCode ^ textViewModelHashCode ^ blockType.hashCode ^ indent.hashCode ^ textScaler.hashCode; } /// A [ComponentBuilder] for rendering hint text in the first node of a document, @@ -433,50 +405,43 @@ class HintComponentViewModel extends SingleColumnLayoutComponentViewModel with T @override HintComponentViewModel copy() { - return HintComponentViewModel( - nodeId: nodeId, - maxWidth: maxWidth, - padding: padding, - text: text, - textStyleBuilder: textStyleBuilder, - textDirection: textDirection, - inlineWidgetBuilders: inlineWidgetBuilders, - indent: indent, - selection: selection, - selectionColor: selectionColor, - highlightWhenEmpty: highlightWhenEmpty, - hintText: hintText, + return internalCopy( + HintComponentViewModel( + nodeId: nodeId, + padding: padding, + text: text, + textStyleBuilder: textStyleBuilder, + selectionColor: selectionColor, + hintText: hintText, + ), ); } @override - // ignore: avoid_equals_and_hash_code_on_mutable_classes + HintComponentViewModel internalCopy(HintComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as HintComponentViewModel; + + copy + ..blockType = blockType + ..indent = indent + ..hintText = hintText; + + return copy; + } + + @override bool operator ==(Object other) => identical(this, other) || super == other && other is HintComponentViewModel && runtimeType == other.runtimeType && - text == other.text && - hintText == other.hintText && - textDirection == other.textDirection && - textAlignment == other.textAlignment && + textViewModelEquals(other) && + blockType == other.blockType && indent == other.indent && - selection == other.selection && - selectionColor == other.selectionColor && - highlightWhenEmpty == other.highlightWhenEmpty; + hintText == hintText; @override - // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => - super.hashCode ^ - text.hashCode ^ - hintText.hashCode ^ - textDirection.hashCode ^ - textAlignment.hashCode ^ - indent.hashCode ^ - selection.hashCode ^ - selectionColor.hashCode ^ - highlightWhenEmpty.hashCode; + int get hashCode => super.hashCode ^ textViewModelHashCode ^ blockType.hashCode ^ indent.hashCode ^ hintText.hashCode; } /// The standard [TextBlockIndentCalculator] used by paragraphs in `SuperEditor`. diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index 1d5818e3d5..d82cb76b09 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -22,6 +22,7 @@ import 'package:super_editor/src/default_editor/layout_single_column/_styler_com import 'package:super_editor/src/default_editor/list_items.dart'; import 'package:super_editor/src/default_editor/tap_handlers/tap_handlers.dart'; import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; import 'package:super_editor/src/infrastructure/content_layers.dart'; import 'package:super_editor/src/infrastructure/documents/document_scaffold.dart'; import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; @@ -609,6 +610,7 @@ class SuperEditorState extends State { pipeline: [ _docStylesheetStyler, _docLayoutPerComponentBlockStyler, + CustomUnderlineStyler(), ...widget.customStylePhases, if (showComposingUnderline) SingleColumnLayoutComposingRegionStyler( diff --git a/super_editor/lib/src/default_editor/tasks.dart b/super_editor/lib/src/default_editor/tasks.dart index f89a7e3761..e84e70a62b 100644 --- a/super_editor/lib/src/default_editor/tasks.dart +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -1,5 +1,4 @@ import 'package:attributed_text/attributed_text.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; @@ -266,69 +265,44 @@ class TaskComponentViewModel extends SingleColumnLayoutComponentViewModel with T @override TaskComponentViewModel copy() { - return TaskComponentViewModel( - nodeId: nodeId, - maxWidth: maxWidth, - padding: padding, - indent: indent, - indentCalculator: indentCalculator, - isComplete: isComplete, - setComplete: setComplete, - text: text, - textStyleBuilder: textStyleBuilder, - inlineWidgetBuilders: inlineWidgetBuilders, - textDirection: textDirection, - textAlignment: textAlignment, - selection: selection, - selectionColor: selectionColor, - highlightWhenEmpty: highlightWhenEmpty, - spellingErrorUnderlineStyle: spellingErrorUnderlineStyle, - spellingErrors: List.from(spellingErrors), - grammarErrorUnderlineStyle: grammarErrorUnderlineStyle, - grammarErrors: List.from(grammarErrors), - composingRegion: composingRegion, - showComposingRegionUnderline: showComposingRegionUnderline, + return internalCopy( + TaskComponentViewModel( + nodeId: nodeId, + padding: padding, + text: text, + textStyleBuilder: textStyleBuilder, + selectionColor: selectionColor, + indent: indent, + isComplete: isComplete, + setComplete: setComplete, + ), ); } + @override + TaskComponentViewModel internalCopy(TaskComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as TaskComponentViewModel; + + copy + ..indent = indent + ..isComplete = isComplete + ..setComplete = setComplete; + + return copy; + } + @override bool operator ==(Object other) => identical(this, other) || super == other && other is TaskComponentViewModel && runtimeType == other.runtimeType && + textViewModelEquals(other) && indent == other.indent && - isComplete == other.isComplete && - text == other.text && - textDirection == other.textDirection && - textAlignment == other.textAlignment && - selection == other.selection && - selectionColor == other.selectionColor && - highlightWhenEmpty == other.highlightWhenEmpty && - spellingErrorUnderlineStyle == other.spellingErrorUnderlineStyle && - const DeepCollectionEquality().equals(spellingErrors, other.spellingErrors) && - grammarErrorUnderlineStyle == other.grammarErrorUnderlineStyle && - const DeepCollectionEquality().equals(grammarErrors, other.grammarErrors) && - composingRegion == other.composingRegion && - showComposingRegionUnderline == other.showComposingRegionUnderline; + isComplete == other.isComplete; @override - int get hashCode => - super.hashCode ^ - indent.hashCode ^ - isComplete.hashCode ^ - text.hashCode ^ - textDirection.hashCode ^ - textAlignment.hashCode ^ - selection.hashCode ^ - selectionColor.hashCode ^ - highlightWhenEmpty.hashCode ^ - spellingErrorUnderlineStyle.hashCode ^ - spellingErrors.hashCode ^ - grammarErrorUnderlineStyle.hashCode ^ - grammarErrors.hashCode ^ - composingRegion.hashCode ^ - showComposingRegionUnderline.hashCode; + int get hashCode => super.hashCode ^ textViewModelHashCode ^ indent.hashCode ^ isComplete.hashCode; } /// The standard [TextBlockIndentCalculator] used by tasks in `SuperEditor`. diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index 945522b0db..5f42b56225 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -15,6 +15,7 @@ import 'package:super_editor/src/core/edit_context.dart'; import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/core/styles.dart'; import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/composable_text.dart'; @@ -25,7 +26,6 @@ import 'package:super_editor/src/infrastructure/strings.dart'; import 'package:super_text_layout/super_text_layout.dart'; import 'layout_single_column/layout_single_column.dart'; -import 'list_items.dart'; import 'multi_node_editing.dart'; import 'paragraph.dart'; import 'selection_upstream_downstream.dart'; @@ -530,6 +530,9 @@ mixin TextComponentViewModel on SingleColumnLayoutComponentViewModel { TextRange? composingRegion; UnderlineStyle composingRegionUnderlineStyle = const StraightUnderlineStyle(); + Set customUnderlines = {}; + CustomUnderlineStyles? customUnderlineStyles; + /// Whether to underline the [composingRegion]. /// /// Showing the underline is optional because the behavior differs between @@ -542,24 +545,41 @@ mixin TextComponentViewModel on SingleColumnLayoutComponentViewModel { List grammarErrors = []; UnderlineStyle grammarErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.blue); - List createUnderlines() { - return [ - if (composingRegion != null && showComposingRegionUnderline) - Underlines( - style: composingRegionUnderlineStyle, - underlines: [composingRegion!], - ), - if (spellingErrors.isNotEmpty) // - Underlines( - style: spellingErrorUnderlineStyle, - underlines: spellingErrors, - ), - if (grammarErrors.isNotEmpty) // - Underlines( - style: grammarErrorUnderlineStyle, - underlines: grammarErrors, - ), - ]; + /// Given a [subclassInstance] of [TextComponentViewModel], copies all base-level text + /// properties from this [TextComponentViewModel] into the given [subclassInstance]. + /// + /// Every view model must implement the ability to copy. Without this method, every subclass + /// would have to repeat the same mapping of properties between the original view model to + /// the copied view model. Originally, that's what Super Editor did, but it became very + /// tedious, and it was error prone because it was easy to accidentally miss a property. + /// + /// From a copy perspective, mutability of view models is important because [TextComponentViewModel] + /// doesn't have a constructor, and because every subclass has different constructors. Therefore, + /// the one approach to consistently support copy is to mutate the parts of a view model + /// that a given class knows about, such as what you see in the implementation of this method. + @protected + TextComponentViewModel internalCopy(covariant TextComponentViewModel subclassInstance) { + subclassInstance + ..maxWidth = maxWidth + ..padding = padding + ..text = text + ..textStyleBuilder = textStyleBuilder + ..inlineWidgetBuilders = inlineWidgetBuilders + ..textDirection = textDirection + ..textAlignment = textAlignment + ..selection = selection + ..selectionColor = selectionColor + ..highlightWhenEmpty = highlightWhenEmpty + ..customUnderlines = Set.from(customUnderlines) + ..customUnderlineStyles = customUnderlineStyles?.copy() + ..spellingErrorUnderlineStyle = spellingErrorUnderlineStyle + ..spellingErrors = List.from(spellingErrors) + ..grammarErrorUnderlineStyle = grammarErrorUnderlineStyle + ..grammarErrors = List.from(grammarErrors) + ..composingRegion = composingRegion + ..showComposingRegionUnderline = showComposingRegionUnderline; + + return subclassInstance; } @override @@ -577,12 +597,84 @@ mixin TextComponentViewModel on SingleColumnLayoutComponentViewModel { inlineWidgetBuilders = styles[Styles.inlineWidgetBuilders] ?? []; + customUnderlineStyles = styles[Styles.customUnderlineStyles]; + composingRegionUnderlineStyle = styles[Styles.composingRegionUnderlineStyle] ?? composingRegionUnderlineStyle; showComposingRegionUnderline = styles[Styles.showComposingRegionUnderline] ?? showComposingRegionUnderline; spellingErrorUnderlineStyle = styles[Styles.spellingErrorUnderlineStyle] ?? spellingErrorUnderlineStyle; grammarErrorUnderlineStyle = styles[Styles.grammarErrorUnderlineStyle] ?? grammarErrorUnderlineStyle; } + + List createUnderlines() { + print("Creating underlines for view model - ${customUnderlines.length} custom underlines"); + return [ + for (final underline in customUnderlines) + Underlines( + // TODO: lookup the actual desired style + style: customUnderlineStyles?.stylesByType[underline.type] ?? const StraightUnderlineStyle(), + underlines: [underline.textRange], + ), + if (composingRegion != null && showComposingRegionUnderline) + Underlines( + style: composingRegionUnderlineStyle, + underlines: [composingRegion!], + ), + if (spellingErrors.isNotEmpty) // + Underlines( + style: spellingErrorUnderlineStyle, + underlines: spellingErrors, + ), + if (grammarErrors.isNotEmpty) // + Underlines( + style: grammarErrorUnderlineStyle, + underlines: grammarErrors, + ), + ]; + } + + bool textViewModelEquals(Object other) => + identical(this, other) || + super == other && + other is TextComponentViewModel && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + maxWidth == other.maxWidth && + padding == other.padding && + text == other.text && + textDirection == other.textDirection && + textAlignment == other.textAlignment && + selection == other.selection && + selectionColor == other.selectionColor && + highlightWhenEmpty == other.highlightWhenEmpty && + customUnderlineStyles == other.customUnderlineStyles && + spellingErrorUnderlineStyle == other.spellingErrorUnderlineStyle && + grammarErrorUnderlineStyle == other.grammarErrorUnderlineStyle && + composingRegion == other.composingRegion && + showComposingRegionUnderline == other.showComposingRegionUnderline && + const DeepCollectionEquality().equals(customUnderlines, other.customUnderlines) && + const DeepCollectionEquality().equals(spellingErrors, other.spellingErrors) && + const DeepCollectionEquality().equals(grammarErrors, other.grammarErrors); + + int get textViewModelHashCode => + super.hashCode ^ + nodeId.hashCode ^ + maxWidth.hashCode ^ + padding.hashCode ^ + text.hashCode ^ + textDirection.hashCode ^ + textAlignment.hashCode ^ + selection.hashCode ^ + selectionColor.hashCode ^ + highlightWhenEmpty.hashCode ^ + customUnderlines.hashCode ^ + customUnderlineStyles.hashCode ^ + spellingErrorUnderlineStyle.hashCode ^ + spellingErrors.hashCode ^ + grammarErrorUnderlineStyle.hashCode ^ + grammarErrors.hashCode ^ + composingRegion.hashCode ^ + showComposingRegionUnderline.hashCode; } /// Document component that displays hint text when its content text diff --git a/super_editor/lib/src/default_editor/text/custom_underlines.dart b/super_editor/lib/src/default_editor/text/custom_underlines.dart new file mode 100644 index 0000000000..a50cad385f --- /dev/null +++ b/super_editor/lib/src/default_editor/text/custom_underlines.dart @@ -0,0 +1,102 @@ +import 'dart:ui'; + +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/styles.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +/// A style phase that inspects [TextComponentViewModel]s, finds text with +/// [CustomUnderlineAttribution]s and adds underline configurations to that +/// view model for each such attribution span. +/// +/// The [TextComponentViewModel]s then configure some kind of `TextComponent`, +/// which finally paints the desired underline. +/// +/// To associate an underline type with a visual style, see [CustomUnderlineStyles]. +class CustomUnderlineStyler extends SingleColumnLayoutStylePhase { + @override + SingleColumnLayoutViewModel style(Document document, SingleColumnLayoutViewModel viewModel) { + final updatedViewModel = SingleColumnLayoutViewModel( + padding: viewModel.padding, + componentViewModels: [ + for (final previousViewModel in viewModel.componentViewModels) // + _applyUnderlines(previousViewModel.copy()), + ], + ); + + return updatedViewModel; + } + + SingleColumnLayoutComponentViewModel _applyUnderlines(SingleColumnLayoutComponentViewModel viewModel) { + if (viewModel is! TextComponentViewModel) { + return viewModel; + } + + final underlineSpans = viewModel.text.getAttributionSpansByFilter((a) => a is CustomUnderlineAttribution); + if (underlineSpans.isEmpty) { + return viewModel; + } + + // Add each attributed underline to the text view model. + viewModel.customUnderlines.clear(); + for (final span in underlineSpans) { + final underlineAttribution = span.attribution as CustomUnderlineAttribution; + + viewModel.customUnderlines.add( + CustomUnderline( + underlineAttribution.type, + TextRange(start: span.start, end: span.end + 1), + // ^ +1 because SpanRange is inclusive and TextRange is exclusive. + ), + ); + } + + return viewModel; + } +} + +/// A data structure that describes how various custom underline styles should +/// be painted. +/// +/// This data structure is a glorified map, which maps from underline names, +/// such as "squiggle", to an underline style, such as `SquiggleUnderlineStyle`. +/// +/// A [CustomUnderlineStyles] can be placed in a document stylesheet in a style +/// rule with a key of [Styles.customUnderlineStyles]. +class CustomUnderlineStyles { + const CustomUnderlineStyles(this.stylesByType); + + /// Map from a custom underline type to its painter. + final Map stylesByType; + + CustomUnderlineStyles copy() { + return CustomUnderlineStyles(Map.from(stylesByType)); + } + + CustomUnderlineStyles addStyles(Map newStyles) { + return CustomUnderlineStyles({ + ...stylesByType, + ...newStyles, + }); + } +} + +/// Data structure, which describes a [type] of underline, which should be painted +/// across the given [textRange]. +/// +/// A [CustomUnderline] applies to a given piece of text - it does not encode any +/// particular document node/position. +class CustomUnderline { + const CustomUnderline(this.type, this.textRange); + + /// A name that represents the type of underline, which maps to some painting + /// style, e.g., "straight", "squiggle". + /// + /// The [type] can be anything - it's meaning is determined by the style system. + final String type; + + /// The range of text within some text block to which this underline applies. + final TextRange textRange; +} diff --git a/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart b/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart index e782a003cd..87b1a2787c 100644 --- a/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart +++ b/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart @@ -6,12 +6,15 @@ import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/content_layers.dart'; -/// Positions invisible widgets around runs of attributed text. +/// Places invisible widgets around runs of attributed text. /// /// The attributions that are bounded are selected with a given [selector]. /// -/// The bounding widget is build with a given [builder], so that any number -/// of use-cases can be implemented with this widget. +/// The bounding widget is built with a given [builder], so that any number +/// of use-cases can be implemented with this widget. This widget is sized +/// as wide and tall as the attributed text run. If text is laid out across +/// multiple lines, the [builder] widget is made as wide and as tall as the +/// bounding box which includes all lines of that text. class AttributionBounds extends ContentLayerStatefulWidget { const AttributionBounds({ Key? key, @@ -128,6 +131,6 @@ class AttributionBoundsLayout { /// should have a widget boundary placed around it. typedef AttributionBoundsSelector = bool Function(Attribution attribution); -/// Builder that (optionally) returns a widget that positioned at the size +/// Builder that (optionally) returns a widget that is positioned at the size /// and location of attributed text. typedef AttributionBoundsBuilder = Widget? Function(BuildContext context, Attribution attribution); diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index b34183c9b7..b8ba71cb77 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -51,6 +51,7 @@ export 'src/default_editor/super_editor.dart'; export 'src/default_editor/tasks.dart'; export 'src/default_editor/text.dart'; export 'src/default_editor/text_tools.dart'; +export 'src/default_editor/text/custom_underlines.dart'; export 'src/default_editor/text_tokenizing/action_tags.dart'; export 'src/default_editor/text_tokenizing/pattern_tags.dart'; export 'src/default_editor/text_tokenizing/tags.dart'; diff --git a/super_editor/pubspec.yaml b/super_editor/pubspec.yaml index a916b0fb1a..f6037e9fac 100644 --- a/super_editor/pubspec.yaml +++ b/super_editor/pubspec.yaml @@ -48,13 +48,13 @@ dependencies: flutter_test_robots: ^0.0.24 clock: ^1.1.1 -#dependency_overrides: +dependency_overrides: # # Override to local mono-repo path so devs can test this repo # # against changes that they're making to other mono-repo packages # attributed_text: # path: ../attributed_text -# super_text_layout: -# path: ../super_text_layout + super_text_layout: + path: ../super_text_layout dev_dependencies: flutter_lints: ^2.0.1 diff --git a/super_text_layout/lib/src/text_underline_layer.dart b/super_text_layout/lib/src/text_underline_layer.dart index da49a8f2e2..2055b019cf 100644 --- a/super_text_layout/lib/src/text_underline_layer.dart +++ b/super_text_layout/lib/src/text_underline_layer.dart @@ -73,6 +73,16 @@ class TextLayoutUnderline { } abstract interface class UnderlineStyle { + /// Vertical offset of the underline (positive or negative) from the bottom + /// edge of the text line's bounding box. + /// + /// Negative moves the underline up, closer to the text, and positive moves + /// it down, further away from the text. + /// + /// Nothing prevents the underline from being pulled into the text, or pushed + /// into the line below the text. That responsibility is up to the developer. + double get offset; + CustomPainter createPainter(List underlines); } @@ -81,15 +91,24 @@ class StraightUnderlineStyle implements UnderlineStyle { this.color = const Color(0xFF000000), this.thickness = 2, this.capType = StrokeCap.square, + this.offset = 0, }); final Color color; final double thickness; + @override + final double offset; final StrokeCap capType; @override CustomPainter createPainter(List underlines) { - return StraightUnderlinePainter(underlines: underlines, color: color, thickness: thickness, capType: capType); + return StraightUnderlinePainter( + underlines: underlines, + color: color, + thickness: thickness, + offset: offset, + capType: capType, + ); } } @@ -98,6 +117,7 @@ class StraightUnderlinePainter extends CustomPainter { required List underlines, this.color = const Color(0xFF000000), this.thickness = 2, + this.offset = 0, this.capType = StrokeCap.square, }) : _underlines = underlines; @@ -106,6 +126,7 @@ class StraightUnderlinePainter extends CustomPainter { final Color color; final double thickness; final StrokeCap capType; + final double offset; @override void paint(Canvas canvas, Size size) { @@ -119,7 +140,7 @@ class StraightUnderlinePainter extends CustomPainter { ..strokeWidth = thickness ..strokeCap = capType; for (final underline in _underlines) { - canvas.drawLine(underline.start, underline.end, linePaint); + canvas.drawLine(underline.start + Offset(0, offset), underline.end + Offset(0, offset), linePaint); } } @@ -128,6 +149,7 @@ class StraightUnderlinePainter extends CustomPainter { return color != oldDelegate.color || thickness != oldDelegate.thickness || capType != oldDelegate.capType || + offset != oldDelegate.offset || !const DeepCollectionEquality().equals(_underlines, oldDelegate._underlines); } } @@ -137,15 +159,24 @@ class DottedUnderlineStyle implements UnderlineStyle { this.color = const Color(0xFFFF0000), this.dotDiameter = 2, this.dotSpace = 1, + this.offset = 0, }); final Color color; final double dotDiameter; final double dotSpace; + @override + final double offset; @override CustomPainter createPainter(List underlines) { - return DottedUnderlinePainter(underlines: underlines, color: color, dotDiameter: dotDiameter, dotSpace: dotSpace); + return DottedUnderlinePainter( + underlines: underlines, + color: color, + offset: offset, + dotDiameter: dotDiameter, + dotSpace: dotSpace, + ); } } @@ -153,6 +184,7 @@ class DottedUnderlinePainter extends CustomPainter { const DottedUnderlinePainter({ required List underlines, this.color = const Color(0xFFFF0000), + this.offset = 0, this.dotDiameter = 2, this.dotSpace = 1, }) : _underlines = underlines; @@ -162,6 +194,7 @@ class DottedUnderlinePainter extends CustomPainter { final Color color; final double dotDiameter; final double dotSpace; + final double offset; @override void paint(Canvas canvas, Size size) { @@ -175,7 +208,7 @@ class DottedUnderlinePainter extends CustomPainter { // Draw the dots. final delta = Offset(dotDiameter + dotSpace, (underline.end.dy - underline.start.dy) / dotCount); - Offset offset = underline.start + Offset(dotDiameter / 2, 0); + Offset offset = underline.start + Offset(dotDiameter / 2, 0) + Offset(0, this.offset); for (int i = 0; i < dotCount; i += 1) { canvas.drawCircle(offset, dotDiameter / 2, dotPaint); offset = offset + delta; @@ -185,7 +218,11 @@ class DottedUnderlinePainter extends CustomPainter { @override bool shouldRepaint(DottedUnderlinePainter oldDelegate) { - return !const DeepCollectionEquality().equals(_underlines, oldDelegate._underlines); + return color != oldDelegate.color || + dotDiameter != oldDelegate.dotDiameter || + dotSpace != oldDelegate.dotSpace || + offset != oldDelegate.offset || + !const DeepCollectionEquality().equals(_underlines, oldDelegate._underlines); } } @@ -193,6 +230,7 @@ class SquiggleUnderlineStyle implements UnderlineStyle { const SquiggleUnderlineStyle({ this.color = const Color(0xFFFF0000), this.thickness = 1, + this.offset = 0, this.jaggedDeltaX = 2, this.jaggedDeltaY = 2, }) : assert(jaggedDeltaX > 0, "The squiggle jaggedDeltaX must be > 0"), @@ -200,6 +238,8 @@ class SquiggleUnderlineStyle implements UnderlineStyle { final Color color; final double thickness; + @override + final double offset; final double jaggedDeltaX; final double jaggedDeltaY; @@ -211,6 +251,7 @@ class SquiggleUnderlineStyle implements UnderlineStyle { thickness: thickness, jaggedDeltaX: jaggedDeltaX, jaggedDeltaY: jaggedDeltaY, + offset: offset, ); } } @@ -220,6 +261,7 @@ class SquiggleUnderlinePainter extends CustomPainter { required List underlines, this.color = const Color(0xFFFF0000), this.thickness = 1, + this.offset = 0, this.jaggedDeltaX = 2, this.jaggedDeltaY = 2, }) : assert(jaggedDeltaX > 0, "The squiggle jaggedDeltaX must be > 0"), @@ -230,6 +272,7 @@ class SquiggleUnderlinePainter extends CustomPainter { final Color color; final double thickness; + final double offset; final double jaggedDeltaX; final double jaggedDeltaY; @@ -247,7 +290,7 @@ class SquiggleUnderlinePainter extends CustomPainter { for (final underline in _underlines) { // Draw the squiggle. - Offset offset = underline.start + Offset(delta.dy / 2, 0); + Offset offset = underline.start + Offset(delta.dy / 2, 0) + Offset(0, this.offset); int nextDirection = -1; while (offset.dx <= underline.end.dx) { // Calculate the endpoint of this jagged squiggle segment. @@ -265,7 +308,12 @@ class SquiggleUnderlinePainter extends CustomPainter { @override bool shouldRepaint(SquiggleUnderlinePainter oldDelegate) { - return !const DeepCollectionEquality().equals(_underlines, oldDelegate._underlines); + return color != oldDelegate.color || + thickness != oldDelegate.thickness || + jaggedDeltaX != oldDelegate.jaggedDeltaX || + jaggedDeltaY != oldDelegate.jaggedDeltaY || + offset != oldDelegate.offset || + !const DeepCollectionEquality().equals(_underlines, oldDelegate._underlines); } } From b5a0fb462dd2f26863cd510fd37e5da6aad55f52 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Sun, 18 May 2025 23:21:53 -0700 Subject: [PATCH 2/2] PR cleanup --- super_editor/lib/src/default_editor/super_editor.dart | 3 ++- super_editor/lib/src/default_editor/text.dart | 2 -- super_editor/lib/src/super_reader/super_reader.dart | 3 +++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index d82cb76b09..aae666ed50 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -380,6 +380,7 @@ class SuperEditorState extends State { SingleColumnLayoutPresenter? _docLayoutPresenter; late SingleColumnStylesheetStyler _docStylesheetStyler; late SingleColumnLayoutCustomComponentStyler _docLayoutPerComponentBlockStyler; + final _customUnderlineStyler = CustomUnderlineStyler(); late SingleColumnLayoutSelectionStyler _docLayoutSelectionStyler; @visibleForTesting @@ -610,7 +611,7 @@ class SuperEditorState extends State { pipeline: [ _docStylesheetStyler, _docLayoutPerComponentBlockStyler, - CustomUnderlineStyler(), + _customUnderlineStyler, ...widget.customStylePhases, if (showComposingUnderline) SingleColumnLayoutComposingRegionStyler( diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index 5f42b56225..1af1abd80b 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -607,11 +607,9 @@ mixin TextComponentViewModel on SingleColumnLayoutComponentViewModel { } List createUnderlines() { - print("Creating underlines for view model - ${customUnderlines.length} custom underlines"); return [ for (final underline in customUnderlines) Underlines( - // TODO: lookup the actual desired style style: customUnderlineStyles?.stylesByType[underline.type] ?? const StraightUnderlineStyle(), underlines: [underline.textRange], ), diff --git a/super_editor/lib/src/super_reader/super_reader.dart b/super_editor/lib/src/super_reader/super_reader.dart index d01b404b15..a7f485e355 100644 --- a/super_editor/lib/src/super_reader/super_reader.dart +++ b/super_editor/lib/src/super_reader/super_reader.dart @@ -37,6 +37,7 @@ import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; import 'package:super_editor/src/infrastructure/platforms/platform.dart'; import 'package:super_editor/src/super_reader/tasks.dart'; +import '../default_editor/text/custom_underlines.dart'; import '../infrastructure/platforms/mobile_documents.dart'; import '../infrastructure/text_input.dart'; import 'read_only_document_android_touch_interactor.dart'; @@ -230,6 +231,7 @@ class SuperReaderState extends State { final _documentLayoutLink = LayerLink(); SingleColumnLayoutPresenter? _docLayoutPresenter; late SingleColumnStylesheetStyler _docStylesheetStyler; + final _customUnderlineStyler = CustomUnderlineStyler(); late SingleColumnLayoutCustomComponentStyler _docLayoutPerComponentBlockStyler; late SingleColumnLayoutSelectionStyler _docLayoutSelectionStyler; @@ -351,6 +353,7 @@ class SuperReaderState extends State { pipeline: [ _docStylesheetStyler, _docLayoutPerComponentBlockStyler, + _customUnderlineStyler, ...widget.customStylePhases, // Selection changes are very volatile. Put that phase last // to minimize view model recalculations.