Skip to content

[SuperEditor][SuperReader] - Custom underline style configuration (Resolves #2675) #2677

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions doc/website/source/super-editor/guides/_data.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
167 changes: 167 additions & 0 deletions doc/website/source/super-editor/guides/styling/text-underlines.md
Original file line number Diff line number Diff line change
@@ -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<LineSegment> 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<LineSegment> underlines,
this.color = const Color(0xFF000000),
this.thickness = 2,
this.capType = StrokeCap.square,
}) : _underlines = underlines;

final List<LineSegment> _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.
Original file line number Diff line number Diff line change
@@ -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<CustomUnderlinesDemo> createState() => _CustomUnderlinesDemoState();
}

class _CustomUnderlinesDemoState extends State<CustomUnderlinesDemo> {
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";
8 changes: 8 additions & 0 deletions super_editor/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -333,6 +334,13 @@ final _menu = <_MenuGroup>[
return const NativeIosContextMenuFeatureDemo();
},
),
_MenuItem(
icon: Icons.line_style,
title: 'Custom Underlines',
pageBuilder: (context) {
return const CustomUnderlinesDemo();
},
),
],
),
_MenuGroup(
Expand Down
7 changes: 7 additions & 0 deletions super_editor/lib/src/core/styles.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down
Loading
Loading