Closed adrian-moisa closed 9 months ago
Hello @adrian-moisa Would love to have this feature as any RTE today supports this out of the box. Would be nice if this also is included into the delta when returning to backend. Quill-react uses something called quill-mentions to achieve this.
Let me know if this feature is up for testing. Thanks :)
@VisakhRaman I will definitely work on this one sooner than later. I need it myself as well, quite high prio. In the meantime I will welcome any implementation suggestions/specs and I will review to see how to best approach this task. So if you have any time to research how this is done, please drop materials here. Thank you!
This is very easy to implement on the user's side, I have already implemented this some while ago without changing quill's source code, I can provide a written tutorial on how to implement such a feature on top of quill, but would really prefer quill not to implement it directly in its code base because what kind of color/shape/style/avatar format/callback when clicked would you want the library to default with? sure it won't exactly fit anyone's design requirement. things like this only make the code base compromise more on elegance and simplicity which at this stage it direly lacks.
@kairan77 Would be helpful, if you can share some code on how you implemented this on top of Quill editor itself.
Yep, please share, eager to see how you did it. Also I'm wondering if a hybrid solution is not maybe better? Like, if you want and have the skills to replace you can replace, otherwise you use the defaults if you are low skilled. Keep in mind that 90% of the users are nowhere close to your skill in terms of text editing. They just need something trusted by the community. Eager to hear your thoughts.
As per your request, the trick is to use link, and handle link differently based on the links content, then in addition to the demo customButton code you also need to write your own urlHandleFunction to handle the onclick event differently for each type, and plug that function into your editor as functional argument.
class MyQuillHashtagButton extends StatefulWidget {
final QuillController controller;
final Future<Map<String, String>> Function(NoteSearchType, String) search;
const MyQuillHashtagButton({
Key? key,
required this.controller,
required this.search,
}) : super(key: key);
@override
State<MyQuillHashtagButton> createState() => _MyQuillHashtagButtonState();
}
class _MyQuillHashtagButtonState extends State<MyQuillHashtagButton> {
@override
void initState() {
super.initState();
widget.controller.addListener(_didChangeSelection);
}
@override
void dispose() {
super.dispose();
widget.controller.removeListener(_didChangeSelection);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isToggled = _getLinkAttributeValue() != null;
return QuillIconButton(
highlightElevation: 0,
hoverElevation: 0,
size: 18 * 1.77,
icon: Icon(
Icons.tag,
size: 18,
color: isToggled ? theme.iconTheme.color : theme.disabledColor,
),
fillColor: theme.canvasColor,
onPressed: () => _openLinkDialog(context),
);
}
void _didChangeSelection() {
setState(() {});
}
void _openLinkDialog(BuildContext context) {
showModal<Tuple2<String, String>>(
context: context,
builder: (ctx) {
final link = _getLinkAttributeValue();
final index = widget.controller.selection.start;
String? text;
if (link != null) {
// text should be the link's corresponding text, not selection
final leaf =
widget.controller.document.querySegmentLeafNode(index).item2;
if (leaf != null) {
text = leaf.toPlainText();
}
}
final len = widget.controller.selection.end - index;
text ??=
len == 0 ? '' : widget.controller.document.getPlainText(index, len);
return _SearchSectionDialog(
link: link,
text: text,
search: widget.search,
);
},
).then(
(value) {
if (value != null) _linkSubmitted(value);
},
);
}
String? _getLinkAttributeValue() {
final attr =
widget.controller.getSelectionStyle().attributes[Attribute.link.key];
return attr?.value.toString();
}
void _linkSubmitted(Tuple2<String, String> value) {
// text.isNotEmpty && link.isNotEmpty
final text = value.item1;
final link = value.item2.trim();
var index = widget.controller.selection.start;
var length = widget.controller.selection.end - index;
if (_getLinkAttributeValue() != null) {
// text should be the link's corresponding text, not selection
final leaf = widget.controller.document.querySegmentLeafNode(index).item2;
if (leaf != null) {
final range = getLinkRange(leaf);
index = range.start;
length = range.end - range.start;
}
}
widget.controller.replaceText(index, length, text, null);
widget.controller.formatText(index, text.length, LinkAttribute(link));
}
}
TextRange getLinkRange(Leaf node) {
var start = node.documentOffset;
var length = node.length;
var prev = node.previous;
final linkAttr = node.style.attributes[Attribute.link.key]!;
while (prev != null) {
if (prev.style.attributes[Attribute.link.key] == linkAttr) {
start = prev.documentOffset;
length += prev.length;
prev = prev.previous;
} else {
break;
}
}
var next = node.next;
while (next != null) {
if (next.style.attributes[Attribute.link.key] == linkAttr) {
length += next.length;
next = next.next;
} else {
break;
}
}
return TextRange(start: start, end: start + length);
}
class _SearchSectionDialog extends StatefulWidget {
final String? link;
final String? text;
final Future<Map<String, String>> Function(NoteSearchType, String) search;
const _SearchSectionDialog({
required this.search,
this.link,
this.text,
Key? key,
}) : super(key: key);
@override
_SearchSectionDialogState createState() => _SearchSectionDialogState();
}
class _SearchSectionDialogState extends State<_SearchSectionDialog> {
final ValueNotifier<String> search = ValueNotifier('');
final ValueNotifier<Map<String, String>> suggestions = ValueNotifier({});
late final TextEditingController _linkController =
TextEditingController(text: _link);
late final TextEditingController _textController =
TextEditingController(text: _text);
late final String _link = widget.link ?? '';
late final String _text = widget.text ?? '';
@override
void initState() {
search.addListener(searchChanged);
super.initState();
}
@override
void dispose() {
search.removeListener(searchChanged);
_linkController.dispose();
_textController.dispose();
search.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 600,
height: 600,
alignment: Alignment.topLeft,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
child: Material(
child: Column(
children: [
const SizedBox(height: 8),
Row(
children: [
// you probably want a ValueNotifier to hold your type here and use drop down to change the search type
TextButton(
onPressed: () {},
child: const Text('Fragment'),
),
Expanded(
child: OneLineInputBox(content: search, hint: 'Searching For Keywords'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ValueListenableBuilder<Map<String, String>>(
valueListenable: suggestions,
builder: (ctx, map, _) {
return map.isEmpty
? const SizedBox.shrink()
: ListView.builder(
itemCount: map.length,
itemBuilder: (ctx, index) {
final ossid = map.keys.toList()[index];
final label = map[ossid]!;
return ListTile(
title: Text(label),
onTap: () => Navigator.pop(
context,
// again instead of reaturning #..., use ValueNotifier on type to return the correct symbol
Tuple2(label, '#$ossid'),
),
);
},
);
},
),
),
],
),
),
),
);
}
Future<void> searchChanged() async {
suggestions.value = search.value.isNotEmpty
? await widget.search(NoteSearchType.section, search.value)
: {};
}
}
enum NoteSearchType {
section,
otherTypeYouWant,
etc,
}
enum NoteSearchTypeExtension on NoteSearchType {
String get marker {
switch(this) {
case section: return '#',
case default: return 'otherSymbolYouWantToDifferentiateDifferentTypeOfLinks',
}
}
}
I don't bother to reformat the code, just copy the code part entirely into your workspace and fix syntax problems, should work.
the search functional parameter you pass into the customButton as argument should return Key, Value pairs in a map based on some search keyword. The key is the text data you want to in the link (usually some sort of id), the value is the text you want to display/highlight in the editor.
When I got time, will come back to write up a clean tutorial on how to implement customButton for the purpose of Hashtag Mentions, then you can link it somewhere in read me.
@adrian-moisa , I guess I should write up the tutorial as a Discussion thread?
Thanks for sharing a detailed breakdown + source code. I will alocate dedicated time to read it in great detail and also research what other options are on hand for this particular need. I will eventually need to get going on this topic by end of autumn. So until then I'll keep the ticket open. If anyone is interested in the topic, they can read to see how you did it. If you have time to write a full in depth tutorial that will be welcomed. But if your time is limited, then this is also good enough. Such features are not accessible to juniors anyway, so any mid or senior reading your samples will know what to do based on what you've already provided.
We could have some built in slash commands:
Hashtags - User @kairan77 has provided a nice demo on how to create a button to add hashtags. He relies on links to add the hashtags. It's a nice setup working within the existing Quill API. He is a strong believer in lightweight libs, and we endorse the same view. However our goal is to go one step further and instead of working with the custom toolbar buttons and controller methods we plan to trigger the interaction from typing shortcuts. We can expose a callback that provides the necessary data for the client dev to position the autocomplete options. This means we keep our implementation as lightweight as possible. Similar to how we did it for the quick menu and markers attachments.
Mentions - A code sample can be found here.