Partial support for table embed (#1960)

pull/1916/head v9.5.0
Cat 9 months ago committed by GitHub
parent 612ef5e0dc
commit e76de1b58e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 75
      flutter_quill_extensions/lib/embeds/table/editor/table_cell_embed.dart
  2. 234
      flutter_quill_extensions/lib/embeds/table/editor/table_embed.dart
  3. 85
      flutter_quill_extensions/lib/embeds/table/editor/table_models.dart
  4. 113
      flutter_quill_extensions/lib/embeds/table/toolbar/table_button.dart
  5. 11
      flutter_quill_extensions/lib/flutter_quill_embeds.dart
  6. 5
      flutter_quill_extensions/lib/flutter_quill_extensions.dart
  7. 27
      flutter_quill_extensions/lib/models/config/table/table_configurations.dart
  8. 85
      flutter_quill_extensions/lib/utils/quill_table_utils.dart

@ -0,0 +1,75 @@
import 'dart:async';
import 'package:flutter/material.dart';
class TableCellWidget extends StatefulWidget {
const TableCellWidget({
required this.cellId,
required this.cellData,
required this.onUpdate,
required this.onTap,
super.key,
});
final String cellId;
final String cellData;
final Function(FocusNode node) onTap;
final Function(String data) onUpdate;
@override
State<TableCellWidget> createState() => _TableCellWidgetState();
}
class _TableCellWidgetState extends State<TableCellWidget> {
late final TextEditingController controller;
late final FocusNode node;
Timer? _debounce;
@override
void initState() {
controller = TextEditingController(text: widget.cellData);
node = FocusNode();
super.initState();
}
void _onTextChanged() {
if (!_debounce!.isActive) {
widget.onUpdate(controller.text);
return;
}
}
@override
void dispose() {
controller
..removeListener(_onTextChanged)
..dispose();
node.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 40,
constraints: const BoxConstraints(
minHeight: 50,
),
padding: const EdgeInsets.only(left: 5, right: 5, top: 5),
child: TextFormField(
controller: controller,
focusNode: node,
keyboardType: TextInputType.multiline,
maxLines: null,
decoration: const InputDecoration.collapsed(hintText: ''),
onTap: () {
widget.onTap.call(node);
},
onTapAlwaysCalled: true,
onChanged: (value) {
_debounce = Timer(
const Duration(milliseconds: 900),
_onTextChanged,
);
},
),
);
}
}

@ -0,0 +1,234 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_quill/quill_delta.dart';
import '../../../utils/quill_table_utils.dart';
import 'table_cell_embed.dart';
import 'table_models.dart';
class CustomTableEmbed extends CustomBlockEmbed {
const CustomTableEmbed(String value) : super(tableType, value);
static const String tableType = 'table';
static CustomTableEmbed fromDocument(Document document) =>
CustomTableEmbed(jsonEncode(document.toDelta().toJson()));
Document get document => Document.fromJson(jsonDecode(data));
}
//Embed builder
class QuillEditorTableEmbedBuilder extends EmbedBuilder {
@override
String get key => 'table';
@override
Widget build(
BuildContext context,
QuillController controller,
Embed node,
bool readOnly,
bool inline,
TextStyle textStyle,
) {
final tableData = node.value.data;
return TableWidget(
tableData: tableData,
controller: controller,
);
}
}
class TableWidget extends StatefulWidget {
const TableWidget({
required this.tableData,
required this.controller,
super.key,
});
final QuillController controller;
final Map<String, dynamic> tableData;
@override
State<TableWidget> createState() => _TableWidgetState();
}
class _TableWidgetState extends State<TableWidget> {
TableModel _tableModel = TableModel(columns: {}, rows: {});
String _selectedColumnId = '';
String _selectedRowId = '';
@override
void initState() {
_tableModel = TableModel.fromMap(widget.tableData);
super.initState();
}
void _addColumn() {
setState(() {
final id = '${_tableModel.columns.length + 1}';
final position = _tableModel.columns.length;
_tableModel.columns[id] = ColumnModel(id: id, position: position);
_tableModel.rows.forEach((key, row) {
row.cells[id] = '';
});
});
_updateTable();
}
void _addRow() {
setState(() {
final id = '${_tableModel.rows.length + 1}';
final cells = <String, String>{};
_tableModel.columns.forEach((key, column) {
cells[key] = '';
});
_tableModel.rows[id] = RowModel(id: id, cells: cells);
});
_updateTable();
}
void _removeColumn(String columnId) {
setState(() {
_tableModel.columns.remove(columnId);
_tableModel.rows.forEach((key, row) {
row.cells.remove(columnId);
});
if (_selectedRowId == _selectedColumnId) {
_selectedRowId = '';
}
_selectedColumnId = '';
});
_updateTable();
}
void _removeRow(String rowId) {
setState(() {
_tableModel.rows.remove(rowId);
_selectedRowId = '';
});
_updateTable();
}
void _updateCell(String columnId, String rowId, String data) {
setState(() {
_tableModel.rows[rowId]!.cells[columnId] = data;
});
_updateTable();
}
void _updateTable() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final offset = getEmbedNode(
widget.controller,
widget.controller.selection.start,
).offset;
final delta = Delta()..insert({'table': _tableModel.toMap()});
widget.controller.replaceText(
offset,
1,
delta,
TextSelection.collapsed(
offset: offset,
),
);
});
}
@override
Widget build(BuildContext context) {
return Material(
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).textTheme.bodyMedium?.color ??
Colors.black)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () async {
final position = renderPosition(context);
await showMenu<TableOperation>(
context: context,
position: position,
items: [
const PopupMenuItem(
value: TableOperation.addColumn,
child: Text('Add column'),
),
const PopupMenuItem(
value: TableOperation.addRow,
child: Text('Add row'),
),
const PopupMenuItem(
value: TableOperation.removeColumn,
child: Text('Delete column'),
),
const PopupMenuItem(
value: TableOperation.removeRow,
child: Text('Delete row'),
),
]).then((value) {
if (value != null) {
if (value == TableOperation.addRow) {
_addRow();
}
if (value == TableOperation.addColumn) {
_addColumn();
}
if (value == TableOperation.removeColumn) {
_removeColumn(_selectedColumnId);
}
if (value == TableOperation.removeRow) {
_removeRow(_selectedRowId);
}
}
});
},
),
const Divider(
color: Colors.white,
height: 1,
),
Table(
border: const TableBorder.symmetric(
inside: BorderSide(color: Colors.white)),
children: _buildTableRows(),
),
],
),
),
);
}
List<TableRow> _buildTableRows() {
final rows = <TableRow>[];
_tableModel.rows.forEach((rowId, rowModel) {
final rowCells = <Widget>[];
final rowKey = rowId;
rowModel.cells.forEach((key, value) {
if (key != 'id') {
final columnId = key;
final data = value;
rowCells.add(TableCellWidget(
cellId: rowKey,
onTap: (node) {
setState(() {
_selectedColumnId = columnId;
_selectedRowId = rowModel.id;
});
},
cellData: data,
onUpdate: (data) => _updateCell(columnId, rowKey, data),
));
}
});
rows.add(TableRow(children: rowCells));
});
return rows;
}
}

@ -0,0 +1,85 @@
class TableModel {
TableModel({required this.columns, required this.rows});
factory TableModel.fromMap(Map<String, dynamic> json) {
return TableModel(
columns: (json['columns'] as Map<String, dynamic>).map(
(key, value) => MapEntry(
key,
ColumnModel.fromMap(
value,
),
),
),
rows: (json['rows'] as Map<String, dynamic>).map(
(key, value) => MapEntry(
key,
RowModel.fromMap(
value,
),
),
),
);
}
Map<String, ColumnModel> columns;
Map<String, RowModel> rows;
Map<String, dynamic> toMap() {
return {
'columns': columns.map(
(key, value) => MapEntry(
key,
value.toMap(),
),
),
'rows': rows.map(
(key, value) => MapEntry(
key,
value.toMap(),
),
),
};
}
}
class ColumnModel {
ColumnModel({required this.id, required this.position});
factory ColumnModel.fromMap(Map<String, dynamic> json) {
return ColumnModel(
id: json['id'],
position: json['position'],
);
}
String id;
int position;
Map<String, dynamic> toMap() {
return {
'id': id,
'position': position,
};
}
}
class RowModel {
// Key is column ID, value is cell content
RowModel({required this.id, required this.cells});
factory RowModel.fromMap(Map<String, dynamic> json) {
return RowModel(
id: json['id'],
cells: Map<String, String>.from(json['cells']),
);
}
String id;
Map<String, String> cells;
Map<String, dynamic> toMap() {
return {
'id': id,
'cells': cells,
};
}
}

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart';
import '../../../models/config/table/table_configurations.dart';
import '../../../utils/quill_table_utils.dart';
class QuillToolbarTableButton extends StatelessWidget {
const QuillToolbarTableButton({
required this.controller,
this.options = const QuillToolbarTableButtonOptions(),
super.key,
});
final QuillController controller;
final QuillToolbarTableButtonOptions options;
double _iconSize(BuildContext context) {
final baseFontSize = baseButtonExtraOptions(context)?.iconSize;
final iconSize = options.iconSize;
return iconSize ?? baseFontSize ?? kDefaultIconSize;
}
double _iconButtonFactor(BuildContext context) {
final baseIconFactor = baseButtonExtraOptions(context)?.iconButtonFactor;
final iconButtonFactor = options.iconButtonFactor;
return iconButtonFactor ?? baseIconFactor ?? kDefaultIconButtonFactor;
}
VoidCallback? _afterButtonPressed(BuildContext context) {
return options.afterButtonPressed ??
baseButtonExtraOptions(context)?.afterButtonPressed;
}
QuillIconTheme? _iconTheme(BuildContext context) {
return options.iconTheme ?? baseButtonExtraOptions(context)?.iconTheme;
}
QuillToolbarBaseButtonOptions? baseButtonExtraOptions(BuildContext context) {
return context.quillToolbarBaseButtonOptions;
}
IconData _iconData(BuildContext context) {
return options.iconData ??
baseButtonExtraOptions(context)?.iconData ??
Icons.table_chart;
}
//TODO: implement translations for table insertion tooltip
String _tooltip(BuildContext context) {
return options.tooltip ??
baseButtonExtraOptions(context)?.tooltip ??
'Insert table';
}
void _sharedOnPressed(BuildContext context) {
_onPressedHandler(context);
_afterButtonPressed(context);
}
@override
Widget build(BuildContext context) {
final tooltip = _tooltip(context);
final iconSize = _iconSize(context);
final iconButtonFactor = _iconButtonFactor(context);
final iconData = _iconData(context);
final childBuilder =
options.childBuilder ?? baseButtonExtraOptions(context)?.childBuilder;
if (childBuilder != null) {
return childBuilder(
QuillToolbarTableButtonOptions(
afterButtonPressed: _afterButtonPressed(context),
iconData: iconData,
iconSize: iconSize,
iconButtonFactor: iconButtonFactor,
iconTheme: options.iconTheme,
tooltip: options.tooltip,
),
QuillToolbarTableButtonExtraOptions(
context: context,
controller: controller,
onPressed: () => _sharedOnPressed(context),
),
);
}
return QuillToolbarIconButton(
icon: Icon(
iconData,
size: iconButtonFactor * iconSize,
),
tooltip: tooltip,
isSelected: false,
onPressed: () => _sharedOnPressed(context),
iconTheme: _iconTheme(context),
);
}
Future<void> _onPressedHandler(BuildContext context) async {
final position = renderPosition(context);
await showMenu(context: context, position: position, items: [
const PopupMenuItem(value: 2, child: Text('2x2')),
const PopupMenuItem(value: 4, child: Text('4x4')),
const PopupMenuItem(value: 6, child: Text('6x6')),
]).then(
(value) {
if (value != null) {
insertTable(value, value, controller, ChangeSource.local);
}
},
);
}
}

@ -6,6 +6,8 @@ import 'embeds/image/editor/image_embed.dart';
import 'embeds/image/editor/image_web_embed.dart';
import 'embeds/image/toolbar/image_button.dart';
import 'embeds/others/camera_button/camera_button.dart';
import 'embeds/table/editor/table_embed.dart';
import 'embeds/table/toolbar/table_button.dart';
import 'embeds/video/editor/video_embed.dart';
import 'embeds/video/editor/video_web_embed.dart';
import 'embeds/video/toolbar/video_button.dart';
@ -13,6 +15,7 @@ import 'models/config/camera/camera_configurations.dart';
import 'models/config/image/editor/image_configurations.dart';
import 'models/config/image/toolbar/image_configurations.dart';
import 'models/config/media/media_button_configurations.dart';
import 'models/config/table/table_configurations.dart';
import 'models/config/video/editor/video_configurations.dart';
import 'models/config/video/editor/video_web_configurations.dart';
import 'models/config/video/toolbar/video_configurations.dart';
@ -61,6 +64,7 @@ class FlutterQuillEmbeds {
QuillEditorVideoEmbedBuilder(
configurations: videoEmbedConfigurations,
),
QuillEditorTableEmbedBuilder(),
];
}
@ -118,6 +122,7 @@ class FlutterQuillEmbeds {
const QuillToolbarVideoButtonOptions(),
QuillToolbarCameraButtonOptions? cameraButtonOptions,
QuillToolbarMediaButtonOptions? mediaButtonOptions,
QuillToolbarTableButtonOptions? tableButtonOptions,
}) =>
[
if (imageButtonOptions != null)
@ -138,6 +143,12 @@ class FlutterQuillEmbeds {
controller: controller,
options: cameraButtonOptions,
),
if (tableButtonOptions != null)
(controller, toolbarIconSize, iconTheme, dialogTheme) =>
QuillToolbarTableButton(
controller: controller,
options: tableButtonOptions,
),
// if (mediaButtonOptions != null)
// (controller, toolbarIconSize, iconTheme, dialogTheme) =>
// QuillToolbarMediaButton(

@ -14,6 +14,10 @@ export 'embeds/image/editor/image_web_embed.dart';
export 'embeds/image/toolbar/image_button.dart';
export 'embeds/others/camera_button/camera_button.dart';
export 'embeds/others/media_button/media_button.dart';
export 'embeds/table/editor/table_cell_embed.dart';
export 'embeds/table/editor/table_embed.dart';
export 'embeds/table/editor/table_models.dart';
export 'embeds/table/toolbar/table_button.dart';
export 'embeds/unknown/editor/unknown_embed.dart';
export 'embeds/video/editor/video_embed.dart';
export 'embeds/video/editor/video_web_embed.dart';
@ -28,6 +32,7 @@ export 'models/config/image/editor/image_web_configurations.dart';
export 'models/config/image/toolbar/image_configurations.dart';
export 'models/config/media/media_button_configurations.dart';
export 'models/config/shared_configurations.dart';
export 'models/config/table/table_configurations.dart';
export 'models/config/video/editor/video_configurations.dart';
export 'models/config/video/editor/video_web_configurations.dart';
export 'models/config/video/toolbar/video_configurations.dart';

@ -0,0 +1,27 @@
import 'package:flutter_quill/flutter_quill.dart';
import 'package:meta/meta.dart' show immutable;
class QuillToolbarTableButtonExtraOptions
extends QuillToolbarBaseButtonExtraOptions {
const QuillToolbarTableButtonExtraOptions({
required super.controller,
required super.context,
required super.onPressed,
});
}
@immutable
class QuillToolbarTableButtonOptions extends QuillToolbarBaseButtonOptions<
QuillToolbarTableButtonOptions, QuillToolbarTableButtonExtraOptions> {
const QuillToolbarTableButtonOptions({
super.iconData,
super.iconSize,
super.iconButtonFactor,
/// specifies the tooltip text for the image button.
super.tooltip,
super.afterButtonPressed,
super.childBuilder,
super.iconTheme,
});
}

@ -0,0 +1,85 @@
import 'package:flutter/widgets.dart'
show
BuildContext,
MediaQuery,
Offset,
Overlay,
Rect,
RelativeRect,
RenderBox,
Size,
TextSelection;
import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_quill/quill_delta.dart';
enum TableOperation {
addColumn,
addRow,
removeColumn,
removeRow,
}
RelativeRect renderPosition(BuildContext context, [Size? size]) {
size ??= MediaQuery.sizeOf(context);
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final button = context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(const Offset(0, -65), ancestor: overlay),
button.localToGlobal(
button.size.bottomRight(Offset.zero) + const Offset(-50, 0),
ancestor: overlay),
),
Offset.zero & size * 0.40,
);
return position;
}
void insertTable(int rows, int columns, QuillController quillController,
ChangeSource? changeFrom) {
final tableData = _createTableData(rows, columns);
final delta = Delta()..insert({'table': tableData});
final selection = quillController.selection;
final replacedLength = selection.extentOffset - selection.baseOffset;
final newBaseOffset = selection.baseOffset;
final newExtentOffsetCandidate =
(selection.baseOffset + 1 - replacedLength).toInt();
final newExtentOffsetAdjusted =
newExtentOffsetCandidate < 0 ? 0 : newExtentOffsetCandidate;
quillController.replaceText(
newBaseOffset,
replacedLength,
delta,
TextSelection(
baseOffset: newBaseOffset, extentOffset: newExtentOffsetAdjusted),
);
}
Map<String, dynamic> _createTableData(int rows, int columns) {
// Crear el mapa para las columnas
final columnsData = <String, dynamic>{};
for (var col = 0; col < columns; col++) {
final columnId = '${col + 1}';
columnsData[columnId] = {'id': columnId, 'position': col};
}
// Crear el mapa para las filas
final rowsData = <String, dynamic>{};
for (var row = 0; row < rows; row++) {
final rowId = '${row + 1}';
rowsData[rowId] = {'id': rowId, 'cells': {}};
for (var col = 0; col < columns; col++) {
final columnId = '${col + 1}';
rowsData[rowId]['cells'][columnId] = '';
}
}
// Combinar las columnas y filas en una estructura de tabla
final tableData = <String, dynamic>{
'columns': columnsData,
'rows': rowsData,
};
return tableData;
}
Loading…
Cancel
Save