From e76de1b58ea49459928bdaa9362d8ccc1e3d3a31 Mon Sep 17 00:00:00 2001 From: Cat <114286961+CatHood0@users.noreply.github.com> Date: Sat, 29 Jun 2024 11:42:34 -0400 Subject: [PATCH] Partial support for table embed (#1960) --- .../embeds/table/editor/table_cell_embed.dart | 75 ++++++ .../lib/embeds/table/editor/table_embed.dart | 234 ++++++++++++++++++ .../lib/embeds/table/editor/table_models.dart | 85 +++++++ .../embeds/table/toolbar/table_button.dart | 113 +++++++++ .../lib/flutter_quill_embeds.dart | 11 + .../lib/flutter_quill_extensions.dart | 5 + .../config/table/table_configurations.dart | 27 ++ .../lib/utils/quill_table_utils.dart | 85 +++++++ 8 files changed, 635 insertions(+) create mode 100644 flutter_quill_extensions/lib/embeds/table/editor/table_cell_embed.dart create mode 100644 flutter_quill_extensions/lib/embeds/table/editor/table_embed.dart create mode 100644 flutter_quill_extensions/lib/embeds/table/editor/table_models.dart create mode 100644 flutter_quill_extensions/lib/embeds/table/toolbar/table_button.dart create mode 100644 flutter_quill_extensions/lib/models/config/table/table_configurations.dart create mode 100644 flutter_quill_extensions/lib/utils/quill_table_utils.dart diff --git a/flutter_quill_extensions/lib/embeds/table/editor/table_cell_embed.dart b/flutter_quill_extensions/lib/embeds/table/editor/table_cell_embed.dart new file mode 100644 index 00000000..27a4f5c6 --- /dev/null +++ b/flutter_quill_extensions/lib/embeds/table/editor/table_cell_embed.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 createState() => _TableCellWidgetState(); +} + +class _TableCellWidgetState extends State { + 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, + ); + }, + ), + ); + } +} diff --git a/flutter_quill_extensions/lib/embeds/table/editor/table_embed.dart b/flutter_quill_extensions/lib/embeds/table/editor/table_embed.dart new file mode 100644 index 00000000..d313f92d --- /dev/null +++ b/flutter_quill_extensions/lib/embeds/table/editor/table_embed.dart @@ -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 tableData; + + @override + State createState() => _TableWidgetState(); +} + +class _TableWidgetState extends State { + 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 = {}; + _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( + 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 _buildTableRows() { + final rows = []; + + _tableModel.rows.forEach((rowId, rowModel) { + final rowCells = []; + 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; + } +} diff --git a/flutter_quill_extensions/lib/embeds/table/editor/table_models.dart b/flutter_quill_extensions/lib/embeds/table/editor/table_models.dart new file mode 100644 index 00000000..2c7ff4e9 --- /dev/null +++ b/flutter_quill_extensions/lib/embeds/table/editor/table_models.dart @@ -0,0 +1,85 @@ +class TableModel { + TableModel({required this.columns, required this.rows}); + + factory TableModel.fromMap(Map json) { + return TableModel( + columns: (json['columns'] as Map).map( + (key, value) => MapEntry( + key, + ColumnModel.fromMap( + value, + ), + ), + ), + rows: (json['rows'] as Map).map( + (key, value) => MapEntry( + key, + RowModel.fromMap( + value, + ), + ), + ), + ); + } + Map columns; + Map rows; + + Map 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 json) { + return ColumnModel( + id: json['id'], + position: json['position'], + ); + } + String id; + int position; + + Map 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 json) { + return RowModel( + id: json['id'], + cells: Map.from(json['cells']), + ); + } + String id; + Map cells; + + Map toMap() { + return { + 'id': id, + 'cells': cells, + }; + } +} diff --git a/flutter_quill_extensions/lib/embeds/table/toolbar/table_button.dart b/flutter_quill_extensions/lib/embeds/table/toolbar/table_button.dart new file mode 100644 index 00000000..37a1e4ff --- /dev/null +++ b/flutter_quill_extensions/lib/embeds/table/toolbar/table_button.dart @@ -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 _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); + } + }, + ); + } +} diff --git a/flutter_quill_extensions/lib/flutter_quill_embeds.dart b/flutter_quill_extensions/lib/flutter_quill_embeds.dart index 2b0efb02..57984b5a 100644 --- a/flutter_quill_extensions/lib/flutter_quill_embeds.dart +++ b/flutter_quill_extensions/lib/flutter_quill_embeds.dart @@ -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( diff --git a/flutter_quill_extensions/lib/flutter_quill_extensions.dart b/flutter_quill_extensions/lib/flutter_quill_extensions.dart index a437ccfb..f5ad43d4 100644 --- a/flutter_quill_extensions/lib/flutter_quill_extensions.dart +++ b/flutter_quill_extensions/lib/flutter_quill_extensions.dart @@ -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'; diff --git a/flutter_quill_extensions/lib/models/config/table/table_configurations.dart b/flutter_quill_extensions/lib/models/config/table/table_configurations.dart new file mode 100644 index 00000000..27d9a44e --- /dev/null +++ b/flutter_quill_extensions/lib/models/config/table/table_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, + }); +} diff --git a/flutter_quill_extensions/lib/utils/quill_table_utils.dart b/flutter_quill_extensions/lib/utils/quill_table_utils.dart new file mode 100644 index 00000000..df2f8d00 --- /dev/null +++ b/flutter_quill_extensions/lib/utils/quill_table_utils.dart @@ -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 _createTableData(int rows, int columns) { + // Crear el mapa para las columnas + final columnsData = {}; + 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 = {}; + 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 = { + 'columns': columnsData, + 'rows': rowsData, + }; + + return tableData; +}