delta_text_view 1.0.0
delta_text_view: ^1.0.0 copied to clipboard
Пакет для отображения Delta формата (используется в Quill редакторе) в виде текста во Flutter
import 'dart:convert';
import 'package:dart_quill_delta/dart_quill_delta.dart';
import 'package:delta_text_view/delta_text_view.dart';
import 'package:delta_text_view_example/edt.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Delta Text View Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const ExamplePage(),
);
}
}
/// Модель упоминания пользователя. Демонстрирует реализацию [MentionDelta].
class UserMention implements MentionDelta {
final String id;
final String name;
UserMention({required this.id, required this.name});
@override
String get displayData => name;
@override
Map<String, dynamic> toJson() => {'id': id, 'name': name};
static UserMention? fromJson(Map<String, dynamic> json) {
final id = json['id'] as String?;
final name = json['name'] as String?;
if (id == null || name == null) return null;
return UserMention(id: id, name: name);
}
}
/// Модель упоминания канала/группы. Другой тип [MentionDelta] — иные поля и стиль.
class ChannelMention implements MentionDelta {
final String channelId;
final String channelName;
ChannelMention({required this.channelId, required this.channelName});
@override
String get displayData => channelName;
@override
Map<String, dynamic> toJson() => {'channelId': channelId, 'channelName': channelName};
static ChannelMention? fromJson(Map<String, dynamic> json) {
final channelId = json['channelId'] as String?;
final channelName = json['channelName'] as String?;
if (channelId == null || channelName == null) return null;
return ChannelMention(channelId: channelId, channelName: channelName);
}
}
/// Пробует распарсить как [UserMention], затем как [ChannelMention].
MentionDelta? _combinedFromJson(Map<String, dynamic> json) =>
UserMention.fromJson(json) ?? ChannelMention.fromJson(json);
Widget _buildMentionWidget(MentionDelta mention) {
if (mention is ChannelMention) {
return Text(
'#${mention.channelName}',
style: const TextStyle(color: Colors.teal, fontWeight: FontWeight.w500),
);
}
return Text(
'@${mention.displayData}',
style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.w500),
);
}
/// [MentionConfig] только для отображения (без tap-callback).
final _displayMentionConfig = MentionConfig(
fromJson: _combinedFromJson,
widgetBuilder: _buildMentionWidget,
);
/// [MentionConfig] с tap — показывает SnackBar. Требует [BuildContext].
MentionConfig _tapMentionConfig(BuildContext context) => MentionConfig(
fromJson: _combinedFromJson,
widgetBuilder: _buildMentionWidget,
onTap: ({required MentionDelta mention, required TapDownDetails details}) {
final String message = switch (mention) {
UserMention m => 'Пользователь: ${m.name} (ID: ${m.id}) globalPosition: ${details.globalPosition}',
ChannelMention m => 'Канал: #${m.channelName} (ID: ${m.channelId}) globalPosition: ${details.globalPosition}',
_ => mention.displayData,
};
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
},
);
/// Секция примера: выделение текста + виджет, отображающий выделенный Delta.
class SelectionDemoSection extends StatefulWidget {
const SelectionDemoSection({
super.key,
required this.title,
required this.richTextDelta,
required this.formattedDelta,
});
final String title;
final Delta richTextDelta;
final Delta formattedDelta;
@override
State<SelectionDemoSection> createState() => _SelectionDemoSectionState();
}
class _SelectionDemoSectionState extends State<SelectionDemoSection> {
Delta? _selectedDelta;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final defaultStyle = TextStyle(
fontSize: 16,
color: theme.colorScheme.onSurface,
);
final mentionConfig = _tapMentionConfig(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: Colors.red,
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
borderRadius: BorderRadius.circular(8),
),
child: ExpandableDeltaText(
key: ValueKey('SelectionDemoSection_MessageText_${widget.richTextDelta.length}'),
maxLinesCollapsed: 10,
defaultStyle: defaultStyle,
mentionConfig: mentionConfig,
selectionColor: theme.colorScheme.primary.withAlpha(100),
delta: _createBasicDelta(),
),
),
Text(
widget.title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Проведите курсором мыши по тексту, чтобы выделить его. '
'Выделенный фрагмент отображается ниже и выводится в консоль (print).',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'DeltaTextView (selectable: true):',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
borderRadius: BorderRadius.circular(8),
),
child: DeltaTextView(emojiConfig: const EmojiConfig(),
mentionConfig: mentionConfig,
delta: widget.richTextDelta,
defaultStyle: defaultStyle,
selectable: true,
onSelectionChangedAsDelta: (selectionDelta) {
if (kDebugMode) {
print('DeltaTextView selection: ${jsonEncode(selectionDelta?.toJson())}');
}
setState(() => _selectedDelta = selectionDelta);
},
),
),
const SizedBox(height: 16),
Text(
'Выделенный фрагмент (из callback):',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
borderRadius: BorderRadius.circular(8),
),
child: _selectedDelta == null
? Text(
'Выделите текст выше — здесь отобразится тот же контент как Delta.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('DeltaTextView:', style: theme.textTheme.labelSmall),
const SizedBox(height: 4),
DeltaTextView(emojiConfig: const EmojiConfig(),
mentionConfig: _displayMentionConfig,
delta: _selectedDelta!,
defaultStyle: defaultStyle,
),
],
),
),
],
);
}
}
class ExamplePage extends StatelessWidget {
const ExamplePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Delta Text View Examples'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSection(
context,
'1. Базовое использование DeltaTextView',
_createBasicDelta(),
withTap: false,
),
const SizedBox(height: 24),
SelectionDemoSection(
title: '2. Выделение текста курсором мыши',
richTextDelta: _createSelectionDemoDelta(),
formattedDelta: _createBlockDelta(),
),
const SizedBox(height: 24),
_buildSection(
context,
'3. Inline форматирование (жирный, курсив, подчеркивание)',
_createInlineFormattingDelta(),
withTap: false,
),
const SizedBox(height: 24),
_buildSection(
context,
'4. Цвета текста и фона',
_createColorDelta(),
withTap: false,
),
const SizedBox(height: 24),
_buildSection(
context,
'5. Ссылки',
_createLinkDelta(),
withTap: false,
),
const SizedBox(height: 24),
_buildSection(
context,
'6. Упоминания (нажмите на @имя)',
_createMentionDelta(),
withTap: true,
),
const SizedBox(height: 24),
_buildSection(
context,
'7. Блоковые элементы (DeltaTextView — текстовые префиксы)',
_createBlockDelta(),
withTap: false,
),
const SizedBox(height: 24),
_buildSection(
context,
'8. Нумерованный список',
_createNumberedListDelta(),
withTap: false,
),
const SizedBox(height: 24),
_buildSection(
context,
'9. Комплексный пример',
_createComplexDelta(),
withTap: true,
),
const SizedBox(height: 24),
_buildSectionSelectionColor(
context,
'10. Кастомный цвет выделения',
_createBasicDelta(),
),
const SizedBox(height: 24),
_buildSectionSelectableDisabled(
context,
'11. Выделение отключено (selectable: false)',
_createBasicDelta(),
),
const SizedBox(height: 24),
_buildSectionOverflow(context),
const SizedBox(height: 24),
_buildSectionScrollPhysics(context),
const SizedBox(height: 24),
_buildSection(
context,
'16. Нумерованный список с list-start',
_createListStartDelta(),
withTap: false,
),
],
),
),
);
}
Widget _buildSection(
BuildContext context,
String title,
Delta delta, {
required bool withTap,
}) {
final mentionConfig = withTap ? _tapMentionConfig(context) : _displayMentionConfig;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)),
borderRadius: BorderRadius.circular(8),
),
child: DeltaTextView(emojiConfig: const EmojiConfig(),
mentionConfig: mentionConfig,
delta: delta,
selectable: true,
onSelectionChangedAsDelta: (selectionDelta) {
print(selectionDelta);
},
defaultStyle: TextStyle(fontSize: 16, color: Theme.of(context).colorScheme.onSurface),
),
),
],
);
}
Widget _buildSectionSelectionColor(BuildContext context, String title, Delta delta) {
final selectionColor = Colors.amber.withValues(alpha: 0.4);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text('DeltaTextView с selectionColor:', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)),
borderRadius: BorderRadius.circular(8),
),
child: DeltaTextView(emojiConfig: const EmojiConfig(),
mentionConfig: _displayMentionConfig,
delta: delta,
defaultStyle: TextStyle(fontSize: 16, color: Theme.of(context).colorScheme.onSurface),
selectionColor: selectionColor,
),
),
],
);
}
Widget _buildSectionSelectableDisabled(BuildContext context, String title, Delta delta) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'Текст нельзя выделить — попробуйте провести по нему.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text('DeltaTextView (selectable: false):', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)),
borderRadius: BorderRadius.circular(8),
),
child: DeltaTextView(emojiConfig: const EmojiConfig(),
mentionConfig: _displayMentionConfig,
delta: delta,
defaultStyle: TextStyle(fontSize: 16, color: Theme.of(context).colorScheme.onSurface),
selectable: false,
),
),
],
);
}
Widget _buildSectionOverflow(BuildContext context) {
final defaultStyle = TextStyle(fontSize: 16, color: Theme.of(context).colorScheme.onSurface);
final longDelta = Delta.fromJson([
{
'insert': 'Длинный текст в одну строку с ellipsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit,'
'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n'
},
]);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'14. Overflow (одна строка с ellipsis)',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'Достаточно передать maxLines и overflow — selectable: false указывать не нужно.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text('DeltaTextView (maxLines: 1, overflow: ellipsis):', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)),
borderRadius: BorderRadius.circular(8),
),
child: DeltaTextView(emojiConfig: const EmojiConfig(),
mentionConfig: _displayMentionConfig,
delta: longDelta,
defaultStyle: defaultStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildSectionScrollPhysics(BuildContext context) {
const scrollHeight = 180.0;
final defaultStyle = TextStyle(fontSize: 16, color: Theme.of(context).colorScheme.onSurface);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'15. Скролл (scrollPhysics)',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'Контент в области фиксированной высоты с разной физикой скролла.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text('DeltaTextView:', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
SizedBox(
height: scrollHeight,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)),
borderRadius: BorderRadius.circular(8),
),
child: DeltaTextView(emojiConfig: const EmojiConfig(),
mentionConfig: _displayMentionConfig,
delta: _createLongScrollableDelta(),
defaultStyle: defaultStyle,
),
),
),
],
);
}
}
// ─── Delta factories ───────────────────────────────────────────────────────────
Delta _createBasicDelta() {
return Delta.fromJson([
{'insert': 'Это простой текст без форматирования.\n'},
{'insert': '🫣😋🙃🙂😉😍😍😜😑\n'},
{
'insert': {'emoji': '😉\n'}
},
]);
}
Delta _createSelectionDemoDelta() {
return Delta.fromJson([
{'insert': 'Выделите этот текст мышью — от начала до конца. '},
{
'insert': 'Форматированный фрагмент',
'attributes': {'bold': true, 'italic': true}
},
{'insert': ' тоже можно выделять. 🔥🔥🔥🔥🔥 Пользователи: '},
{
'insert': {'mention': UserMention(id: 'u1', name: 'Анна').toJson()}
},
{'insert': ', '},
{
'insert': {'mention': UserMention(id: 'u2', name: 'Борис').toJson()}
},
{'insert': '. Каналы: '},
{
'insert': {'mention': ChannelMention(channelId: 'ch1', channelName: 'general').toJson()}
},
{'insert': ', '},
{
'insert': {'mention': ChannelMention(channelId: 'ch2', channelName: 'разработка').toJson()}
},
{'insert': ' — все упоминания входят в выделение и Delta.\n'},
{'insert': '\n'},
{'insert': '\n'},
{'insert': '\n'},
{
"insert": "asd",
"attributes": {"link": "https://gitlab.satel.org/rtuc-forks/frontend/delta_widget/-/tree/1.3.2?ref_type=heads"}
},
{
"insert": "\n",
"attributes": {"list": "ordered"}
},
{"insert": "qwe"},
{
"insert": "\n",
"attributes": {"list": "ordered"}
},
{"insert": "rty"},
{
"insert": "\n",
"attributes": {"list": "ordered"}
},
{"insert": "uio"},
{
"insert": "\n",
"attributes": {"list": "ordered"}
}
]);
}
Delta _createInlineFormattingDelta() {
return Delta.fromJson([
{'insert': 'Обычный текст, '},
{
'insert': 'жирный текст',
'attributes': {'bold': true}
},
{'insert': ', '},
{
'insert': 'курсив',
'attributes': {'italic': true}
},
{'insert': ', '},
{
'insert': 'подчеркнутый',
'attributes': {'underline': true}
},
{'insert': ', '},
{
'insert': 'зачеркнутый',
'attributes': {'strike': true}
},
{'insert': '.\n'},
]);
}
Delta _createColorDelta() {
return Delta.fromJson([
{'insert': 'Текст с '},
{
'insert': 'красным цветом',
'attributes': {'color': '#FF0000'}
},
{'insert': ' и '},
{
'insert': 'зеленым фоном',
'attributes': {'background': '#00FF00'}
},
{'insert': '.\n'},
]);
}
Delta _createLinkDelta() {
return Delta.fromJson([
{'insert': 'Ссылка на '},
{
'insert': 'Google',
'attributes': {'link': 'https://www.google.com'}
},
{'insert': ' и '},
{
'insert': 'GitHub',
'attributes': {'link': 'https://github.com'}
},
{'insert': '.\n'},
]);
}
Delta _createMentionDelta() {
return Delta.fromJson([
{'insert': 'Привет, '},
{
'insert': {'mention': UserMention(id: 'user123', name: 'Иван Иванов').toJson()}
},
{'insert': ' и '},
{
'insert': {'mention': UserMention(id: 'user456', name: 'Елена').toJson()}
},
{'insert': '! Обсуждение в '},
{
'insert': {'mention': ChannelMention(channelId: 'ch10', channelName: 'general').toJson()}
},
{'insert': ' и '},
{
'insert': {'mention': ChannelMention(channelId: 'ch11', channelName: 'random').toJson()}
},
{'insert': '.\n'},
]);
}
Delta _createLongScrollableDelta() {
final lines = <Map<String, dynamic>>[];
for (var i = 1; i <= 15; i++) {
lines.add({'insert': 'Строка $i. Прокрутите контент вверх и вниз.\n'});
}
return Delta.fromJson(lines);
}
Delta _createBlockDelta() {
return Delta.fromJson([
{
'insert': 'Заголовок 1',
'attributes': {'header': 1}
},
{'insert': '\nОбычный параграф.\n'},
{
'insert': 'Заголовок 2',
'attributes': {'header': 2}
},
{
'insert': '\nЭлемент списка 1',
'attributes': {'list': 'bullet'}
},
{
'insert': '\nЭлемент списка 2',
'attributes': {'list': 'bullet'}
},
{
'insert': '\nЦитата',
'attributes': {'blockquote': true}
},
{
'insert': '\nБлок кода',
'attributes': {'code-block': true}
},
{'insert': '\n'},
]);
}
Delta _createNumberedListDelta() {
return Delta.fromJson([
{
"insert": "asd",
"attributes": {"link": "https://gitlab.satel.org/rtuc-forks/frontend/delta_widget/-/tree/1.3.2?ref_type=heads"}
},
{
"insert": "\n",
"attributes": {"list": "ordered"}
},
{"insert": "qwe"},
{
"insert": "\n",
"attributes": {"list": "ordered"}
},
{"insert": "rty"},
{
"insert": "\n",
"attributes": {"list": "ordered"}
},
{"insert": "uio"},
{
"insert": "\n",
"attributes": {"list": "ordered"}
}
]);
}
Delta _createComplexDelta() {
return Delta.fromJson([
{
'insert': 'Комплексный пример',
'attributes': {'header': 1}
},
{'insert': '\nЭтот текст содержит '},
{
'insert': 'жирный',
'attributes': {'bold': true}
},
{'insert': ' и '},
{
'insert': 'курсивный',
'attributes': {'italic': true}
},
{'insert': ' текст.\n'},
{
'insert': 'Список покупок:',
'attributes': {'header': 2}
},
{
'insert': '\nМолоко',
'attributes': {'list': 'bullet'}
},
{
'insert': '\nХлеб',
'attributes': {'list': 'bullet'}
},
{
'insert': '\nЯйца',
'attributes': {'list': 'bullet'}
},
{
'insert': '\nВажная цитата:',
'attributes': {'header': 2}
},
{
'insert': '\n"Код пишется один раз, а читается тысячи раз."',
'attributes': {'blockquote': true}
},
{
'insert': '\nПример кода:',
'attributes': {'header': 2}
},
{
'insert': '\nvoid main() {\n print("Hello, World!");\n}',
'attributes': {'code-block': true}
},
{'insert': '\nСвяжитесь с '},
{
'insert': {'mention': UserMention(id: 'user456', name: 'Петр Петров').toJson()}
},
{'insert': ' для вопросов.\n'},
{
'insert': 'Посетите наш сайт',
'attributes': {'link': 'https://example.com', 'bold': true}
},
{'insert': '.\n'},
]);
}
Delta _createListStartDelta() {
return Delta.fromJson([
{
"insert": "обычный текст\n",
"attributes": {"list": "ordered", "list-real-index": 1}
},
{
"insert": "жирный",
"attributes": {"bold": true}
},
{
"insert": " текст\n",
"attributes": {"list": "ordered", "list-real-index": 2}
},
{
"insert": "курсив",
"attributes": {"italic": true}
},
{
"insert": " текст\n",
"attributes": {"list": "ordered", "list-real-index": 3}
},
{
"insert": "подчеркнутый",
"attributes": {"underline": true}
},
{
"insert": " текст\n",
"attributes": {"list": "ordered", "list-real-index": 4}
},
{
"insert": "красный",
"attributes": {"color": "#ff0000"}
},
{
"insert": " текст\n",
"attributes": {"list": "ordered", "list-real-index": 5}
},
{
"insert": "зачеркнутый",
"attributes": {"strike": true}
},
{
"insert": " текст\n",
"attributes": {"list": "ordered", "list-real-index": 6}
},
{
"insert": "жирный ",
"attributes": {"bold": true}
},
{
"insert": "и красный",
"attributes": {"bold": true, "color": "#ff0000"}
},
{
"insert": " текст\n",
"attributes": {"list": "ordered", "list-real-index": 7}
},
{
"insert": "код",
"attributes": {"code": true}
},
{
"insert": " текст\n",
"attributes": {"list": "ordered", "list-real-index": 8}
}
]);
}