Allow custom attributes to be added into the Attribute registry.

Export the Rule class so that it's possible to add custom rules.
Make the custom button apply the styles defined in the icon theme and add isToggled function to determine whether the button should apply the toggled style. Also, allow the button to be further customizable by defining a custom builder implementation.
Fix the demo pages and add a demo page showcasing a custom attribute.
pull/971/head
kuba 3 years ago
parent 1f42da82fb
commit 4face9dc23
  1. 75
      README.md
  2. 128
      example/lib/pages/custom_attr_page.dart
  3. 19
      example/lib/pages/home_page.dart
  4. 5
      example/lib/pages/read_only_page.dart
  5. 35
      example/lib/widgets/demo_scaffold.dart
  6. 1
      lib/flutter_quill.dart
  7. 4
      lib/src/models/documents/attribute.dart
  8. 131
      lib/src/models/themes/quill_custom_button.dart
  9. 12
      lib/src/widgets/toolbar.dart

@ -153,6 +153,81 @@ QuillToolbar.basic(
]
```
### Custom Attributes
Often times we want to apply a style that is not available by default with the use of existing toolbar buttons. For such cases, we can simply add a custom toolbar button to apply a custom attribute to the selected text.
We can create a custom attribute by defining the class as such:
```dart
class CustomAttribute extends Attribute<bool?> {
const CustomAttribute(bool? val)
: super(KEY, AttributeScope.INLINE, val);
static const String KEY = 'my-custom-attr';
}
```
We should then register the attribute when the app is started.
```dart
void main() {
Attribute.addCustomAttribute(const CustomAttribute(null));
runApp(MyApp());
}
```
After that we can define a custom style builder and handle the text style that will be applied for the given attribute. For example, the below code will apply a blue color and yellow background if a text contains this attribute.
```dart
TextStyle _customStyleBuilder(Attribute attr) {
if (attr.key == CustomAttribute.KEY) {
return TextStyle(
color: Colors.blue, backgroundColor: Colors.yellow);
}
return const TextStyle();
}
Widget build(BuildContext context) {
var quillEditor = QuillEditor(
...
customStyleBuilder: _customStyleBuilder,
);
...
}
```
You can then add a custom toolbar button that will apply this attribute to the selected text.
```dart
var toolbar = QuillToolbar.basic(
controller: controller,
customButtons: [
QuillCustomButton(
icon: Icons.smart_toy_sharp,
onTap: () {
if (controller
.getSelectionStyle()
.attributes
.keys
.contains(CustomAttribute.KEY)) {
// remove the attribute if it's already applied
controller
.formatSelection(const CustomAttribute(null));
} else {
// add the attribute
controller
.formatSelection(const CustomAttribute(true));
}
},
isToggled: () {
// determine whether the button should be in a toggled state
return controller
.getSelectionStyle()
.attributes
.containsKey(CustomAttribute.KEY);
})
],
);
```
## Embed Blocks

@ -0,0 +1,128 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
import 'package:flutter_quill_extensions/flutter_quill_extensions.dart';
import '../universal_ui/universal_ui.dart';
import '../widgets/demo_scaffold.dart';
class CustomAttrPage extends StatefulWidget {
CustomAttrPage() {
// should probably be called when the app first starts but since the
// registry is a hashmap then it won't really matter for this example
Attribute.addCustomAttribute(const RandomColorAttribute(true));
}
@override
_CustomAttrPageState createState() => _CustomAttrPageState();
}
class _CustomAttrPageState extends State<CustomAttrPage> {
final FocusNode _focusNode = FocusNode();
QuillController? _controller;
@override
Widget build(BuildContext context) {
return DemoScaffold(
documentFilename: 'sample_data_nomedia.json',
builder: _buildContent,
customButtons: [
QuillCustomButton(
icon: Icons.smart_toy_sharp,
onTap: () {
if (_controller != null) {
if (_controller!
.getSelectionStyle()
.attributes
.keys
.contains(RandomColorAttribute.KEY)) {
_controller!
.formatSelection(const RandomColorAttribute(null));
} else {
_controller!
.formatSelection(const RandomColorAttribute(true));
}
}
},
isToggled: () {
return _controller != null &&
_controller!
.getSelectionStyle()
.attributes
.containsKey(RandomColorAttribute.KEY);
})
],
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
// update ui and randomize the colors
});
},
child: const Icon(Icons.refresh),
),
title: 'Custom attribute demo',
);
}
Widget _buildContent(BuildContext context, QuillController? controller) {
_controller = controller;
var quillEditor = QuillEditor(
controller: controller!,
scrollController: ScrollController(),
scrollable: true,
focusNode: _focusNode,
autoFocus: true,
readOnly: false,
expands: false,
padding: EdgeInsets.zero,
embedBuilders: FlutterQuillEmbeds.builders(),
customStyleBuilder: _customStyleBuilder,
);
if (kIsWeb) {
quillEditor = QuillEditor(
controller: controller,
scrollController: ScrollController(),
scrollable: true,
focusNode: _focusNode,
autoFocus: true,
readOnly: false,
expands: false,
padding: EdgeInsets.zero,
embedBuilders: defaultEmbedBuildersWeb,
customStyleBuilder: _customStyleBuilder,
);
}
return Padding(
padding: const EdgeInsets.all(8),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade200),
),
child: quillEditor,
),
);
}
TextStyle _customStyleBuilder(Attribute attr) {
if (attr.key == RandomColorAttribute.KEY) {
// generate a random text color
return TextStyle(
color:
Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1));
}
return const TextStyle();
}
}
// custom inline attribute
class RandomColorAttribute extends Attribute<bool?> {
const RandomColorAttribute(bool? val)
: super(KEY, AttributeScope.INLINE, val);
static const String KEY = 'random-color';
}

@ -15,6 +15,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:tuple/tuple.dart';
import '../universal_ui/universal_ui.dart';
import 'custom_attr_page.dart';
import 'read_only_page.dart';
class HomePage extends StatefulWidget {
@ -342,11 +343,19 @@ class _HomePageState extends State<HomePage> {
indent: size.width * 0.1,
endIndent: size.width * 0.1,
),
ListTile(
title:
const Center(child: Text('Custom attr demo', style: itemStyle)),
dense: true,
visualDensity: VisualDensity.compact,
onTap: _openCustomAttrPage,
),
],
);
}
void _readOnly() {
Navigator.pop(super.context);
Navigator.push(
super.context,
MaterialPageRoute(
@ -355,6 +364,16 @@ class _HomePageState extends State<HomePage> {
);
}
void _openCustomAttrPage() {
Navigator.pop(super.context);
Navigator.push(
super.context,
MaterialPageRoute(
builder: (context) => CustomAttrPage(),
),
);
}
Future<String> _onImagePaste(Uint8List imageBytes) async {
// Saves the image to applications directory
final appDocDir = await getApplicationDocumentsDirectory();

@ -20,15 +20,14 @@ class _ReadOnlyPageState extends State<ReadOnlyPage> {
@override
Widget build(BuildContext context) {
return DemoScaffold(
documentFilename: isDesktop()
? 'assets/sample_data_nomedia.json'
: 'sample_data_nomedia.json',
documentFilename: 'sample_data_nomedia.json',
builder: _buildContent,
showToolbar: _edit == true,
floatingActionButton: FloatingActionButton.extended(
label: Text(_edit == true ? 'Done' : 'Edit'),
onPressed: _toggleEdit,
icon: Icon(_edit == true ? Icons.check : Icons.edit)),
title: 'Read only demo',
);
}

@ -20,6 +20,8 @@ class DemoScaffold extends StatefulWidget {
this.actions,
this.showToolbar = true,
this.floatingActionButton,
this.customButtons,
this.title = '',
Key? key,
}) : super(key: key);
@ -29,24 +31,23 @@ class DemoScaffold extends StatefulWidget {
final List<Widget>? actions;
final Widget? floatingActionButton;
final bool showToolbar;
final List<QuillCustomButton>? customButtons;
final String title;
@override
_DemoScaffoldState createState() => _DemoScaffoldState();
}
class _DemoScaffoldState extends State<DemoScaffold> {
final _scaffoldKey = GlobalKey<ScaffoldState>();
QuillController? _controller;
bool _loading = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller == null && !_loading) {
_loading = true;
_loadFromAssets();
}
void initState() {
super.initState();
_loading = true;
_loadFromAssets();
}
@override
@ -93,36 +94,34 @@ class _DemoScaffoldState extends State<DemoScaffold> {
var toolbar = QuillToolbar.basic(
controller: _controller!,
embedButtons: FlutterQuillEmbeds.buttons(),
customButtons: widget.customButtons ?? [],
);
if (_isDesktop()) {
toolbar = QuillToolbar.basic(
controller: _controller!,
embedButtons: FlutterQuillEmbeds.buttons(
filePickImpl: openFileSystemPickerForDesktop),
customButtons: widget.customButtons ?? [],
);
}
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
elevation: 0,
backgroundColor: Theme.of(context).canvasColor,
backgroundColor: Colors.grey.shade800,
title: Text(widget.title),
centerTitle: false,
titleSpacing: 0,
leading: IconButton(
icon: Icon(
Icons.chevron_left,
color: Colors.grey.shade800,
size: 18,
),
onPressed: () => Navigator.pop(context),
),
title: _loading || !widget.showToolbar ? null : toolbar,
actions: actions,
),
floatingActionButton: widget.floatingActionButton,
body: _loading
? const Center(child: Text('Loading...'))
: widget.builder(context, _controller),
bottomNavigationBar: _loading || !widget.showToolbar
? null
: BottomAppBar(
child: toolbar,
),
);
}

@ -7,6 +7,7 @@ export 'src/models/documents/nodes/leaf.dart';
export 'src/models/documents/nodes/node.dart';
export 'src/models/documents/style.dart';
export 'src/models/quill_delta.dart';
export 'src/models/rules/rule.dart';
export 'src/models/themes/quill_custom_button.dart';
export 'src/models/themes/quill_dialog_theme.dart';
export 'src/models/themes/quill_icon_theme.dart';

@ -17,6 +17,10 @@ class Attribute<T> {
final AttributeScope scope;
final T value;
static void addCustomAttribute(Attribute attr) {
_registry[attr.key] = attr;
}
static final Map<String, Attribute> _registry = LinkedHashMap.of({
Attribute.bold.key: Attribute.bold,
Attribute.italic.key: Attribute.italic,

@ -1,11 +1,136 @@
import 'package:flutter/material.dart';
import '../../../flutter_quill.dart';
class QuillCustomButton {
const QuillCustomButton({this.icon, this.onTap});
const QuillCustomButton(
{this.icon,
this.onTap,
this.isToggled,
this.builder = defaultCustomButtonBuilder});
///The icon widget
// The icon widget
final IconData? icon;
///The function when the icon is tapped
// The function when the icon is tapped
final VoidCallback? onTap;
// The function to determine whether the button is toggled
final CustomButtonToggled? isToggled;
// Can specify a custom builder to build the widget
final CustomButtonBuilder builder;
}
class QuillCustomButtonWidget extends StatefulWidget {
const QuillCustomButtonWidget(
{required this.button,
required this.controller,
required this.iconSize,
this.iconTheme,
this.afterPressed});
final QuillCustomButton button;
final QuillController controller;
final double iconSize;
final QuillIconTheme? iconTheme;
final VoidCallback? afterPressed;
@override
State<QuillCustomButtonWidget> createState() =>
_QuillCustomButtonWidgetState();
}
class _QuillCustomButtonWidgetState extends State<QuillCustomButtonWidget> {
bool _toggled = false;
@override
void initState() {
super.initState();
// set the initial toggled state
_toggled =
widget.button.isToggled != null ? widget.button.isToggled!() : false;
// add listener to update the toggled state
widget.controller.addListener(_didChangeEditingValue);
}
@override
void dispose() {
widget.controller.removeListener(_didChangeEditingValue);
super.dispose();
}
void _didChangeEditingValue() {
final toggled =
widget.button.isToggled != null ? widget.button.isToggled!() : false;
if (toggled != _toggled) {
setState(() {
_toggled = toggled;
});
}
}
@override
Widget build(BuildContext context) {
return widget.button.builder(
context,
widget.controller,
widget.button.icon,
widget.iconSize,
widget.iconTheme,
_toggled,
widget.button.onTap,
widget.afterPressed);
}
}
typedef CustomButtonBuilder = Widget Function(
BuildContext context,
QuillController controller,
IconData? icon,
double iconSize,
QuillIconTheme? iconTheme,
bool isToggled,
VoidCallback? onPressed,
VoidCallback? afterPressed,
);
Widget defaultCustomButtonBuilder(
BuildContext context,
QuillController controller,
IconData? icon,
double iconSize,
QuillIconTheme? iconTheme,
bool isToggled,
VoidCallback? onPressed,
VoidCallback? afterPressed,
) {
final theme = Theme.of(context);
final isEnabled = onPressed != null;
final iconColor = isEnabled
? isToggled
? (iconTheme?.iconSelectedColor ?? theme.primaryIconTheme.color)
: (iconTheme?.iconUnselectedColor ?? theme.iconTheme.color)
: (iconTheme?.disabledIconColor ?? theme.disabledColor);
final fill = isEnabled
? isToggled
? (iconTheme?.iconSelectedFillColor ?? theme.toggleableActiveColor)
: (iconTheme?.iconUnselectedFillColor ?? theme.canvasColor)
: (iconTheme?.disabledIconFillColor ?? theme.canvasColor);
return QuillIconButton(
highlightElevation: 0,
hoverElevation: 0,
size: iconSize * kIconButtonFactor,
icon: Icon(icon, size: iconSize, color: iconColor),
fillColor: fill,
onPressed: onPressed,
afterPressed: afterPressed,
borderRadius: iconTheme?.borderRadius ?? 2,
);
}
typedef CustomButtonToggled = bool Function();

@ -486,13 +486,11 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
color: Colors.grey.shade400,
),
for (var customButton in customButtons)
QuillIconButton(
highlightElevation: 0,
hoverElevation: 0,
size: toolbarIconSize * kIconButtonFactor,
icon: Icon(customButton.icon, size: toolbarIconSize),
borderRadius: iconTheme?.borderRadius ?? 2,
onPressed: customButton.onTap,
QuillCustomButtonWidget(
button: customButton,
controller: controller,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
afterPressed: afterButtonPressed,
),
],

Loading…
Cancel
Save