Skip to content

Commit e76de1b

Browse files
authored
Partial support for table embed (singerdmx#1960)
1 parent 612ef5e commit e76de1b

File tree

8 files changed

+635
-0
lines changed

8 files changed

+635
-0
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import 'dart:async';
2+
import 'package:flutter/material.dart';
3+
4+
class TableCellWidget extends StatefulWidget {
5+
const TableCellWidget({
6+
required this.cellId,
7+
required this.cellData,
8+
required this.onUpdate,
9+
required this.onTap,
10+
super.key,
11+
});
12+
final String cellId;
13+
final String cellData;
14+
final Function(FocusNode node) onTap;
15+
final Function(String data) onUpdate;
16+
17+
@override
18+
State<TableCellWidget> createState() => _TableCellWidgetState();
19+
}
20+
21+
class _TableCellWidgetState extends State<TableCellWidget> {
22+
late final TextEditingController controller;
23+
late final FocusNode node;
24+
Timer? _debounce;
25+
@override
26+
void initState() {
27+
controller = TextEditingController(text: widget.cellData);
28+
node = FocusNode();
29+
super.initState();
30+
}
31+
32+
void _onTextChanged() {
33+
if (!_debounce!.isActive) {
34+
widget.onUpdate(controller.text);
35+
return;
36+
}
37+
}
38+
39+
@override
40+
void dispose() {
41+
controller
42+
..removeListener(_onTextChanged)
43+
..dispose();
44+
node.dispose();
45+
super.dispose();
46+
}
47+
48+
@override
49+
Widget build(BuildContext context) {
50+
return Container(
51+
width: 40,
52+
constraints: const BoxConstraints(
53+
minHeight: 50,
54+
),
55+
padding: const EdgeInsets.only(left: 5, right: 5, top: 5),
56+
child: TextFormField(
57+
controller: controller,
58+
focusNode: node,
59+
keyboardType: TextInputType.multiline,
60+
maxLines: null,
61+
decoration: const InputDecoration.collapsed(hintText: ''),
62+
onTap: () {
63+
widget.onTap.call(node);
64+
},
65+
onTapAlwaysCalled: true,
66+
onChanged: (value) {
67+
_debounce = Timer(
68+
const Duration(milliseconds: 900),
69+
_onTextChanged,
70+
);
71+
},
72+
),
73+
);
74+
}
75+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import 'dart:convert';
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_quill/flutter_quill.dart';
5+
import 'package:flutter_quill/quill_delta.dart';
6+
import '../../../utils/quill_table_utils.dart';
7+
import 'table_cell_embed.dart';
8+
import 'table_models.dart';
9+
10+
class CustomTableEmbed extends CustomBlockEmbed {
11+
const CustomTableEmbed(String value) : super(tableType, value);
12+
13+
static const String tableType = 'table';
14+
15+
static CustomTableEmbed fromDocument(Document document) =>
16+
CustomTableEmbed(jsonEncode(document.toDelta().toJson()));
17+
18+
Document get document => Document.fromJson(jsonDecode(data));
19+
}
20+
21+
//Embed builder
22+
23+
class QuillEditorTableEmbedBuilder extends EmbedBuilder {
24+
@override
25+
String get key => 'table';
26+
27+
@override
28+
Widget build(
29+
BuildContext context,
30+
QuillController controller,
31+
Embed node,
32+
bool readOnly,
33+
bool inline,
34+
TextStyle textStyle,
35+
) {
36+
final tableData = node.value.data;
37+
return TableWidget(
38+
tableData: tableData,
39+
controller: controller,
40+
);
41+
}
42+
}
43+
44+
class TableWidget extends StatefulWidget {
45+
const TableWidget({
46+
required this.tableData,
47+
required this.controller,
48+
super.key,
49+
});
50+
final QuillController controller;
51+
final Map<String, dynamic> tableData;
52+
53+
@override
54+
State<TableWidget> createState() => _TableWidgetState();
55+
}
56+
57+
class _TableWidgetState extends State<TableWidget> {
58+
TableModel _tableModel = TableModel(columns: {}, rows: {});
59+
String _selectedColumnId = '';
60+
String _selectedRowId = '';
61+
62+
@override
63+
void initState() {
64+
_tableModel = TableModel.fromMap(widget.tableData);
65+
super.initState();
66+
}
67+
68+
void _addColumn() {
69+
setState(() {
70+
final id = '${_tableModel.columns.length + 1}';
71+
final position = _tableModel.columns.length;
72+
_tableModel.columns[id] = ColumnModel(id: id, position: position);
73+
_tableModel.rows.forEach((key, row) {
74+
row.cells[id] = '';
75+
});
76+
});
77+
_updateTable();
78+
}
79+
80+
void _addRow() {
81+
setState(() {
82+
final id = '${_tableModel.rows.length + 1}';
83+
final cells = <String, String>{};
84+
_tableModel.columns.forEach((key, column) {
85+
cells[key] = '';
86+
});
87+
_tableModel.rows[id] = RowModel(id: id, cells: cells);
88+
});
89+
_updateTable();
90+
}
91+
92+
void _removeColumn(String columnId) {
93+
setState(() {
94+
_tableModel.columns.remove(columnId);
95+
_tableModel.rows.forEach((key, row) {
96+
row.cells.remove(columnId);
97+
});
98+
if (_selectedRowId == _selectedColumnId) {
99+
_selectedRowId = '';
100+
}
101+
_selectedColumnId = '';
102+
});
103+
_updateTable();
104+
}
105+
106+
void _removeRow(String rowId) {
107+
setState(() {
108+
_tableModel.rows.remove(rowId);
109+
_selectedRowId = '';
110+
});
111+
_updateTable();
112+
}
113+
114+
void _updateCell(String columnId, String rowId, String data) {
115+
setState(() {
116+
_tableModel.rows[rowId]!.cells[columnId] = data;
117+
});
118+
_updateTable();
119+
}
120+
121+
void _updateTable() {
122+
WidgetsBinding.instance.addPostFrameCallback((_) {
123+
final offset = getEmbedNode(
124+
widget.controller,
125+
widget.controller.selection.start,
126+
).offset;
127+
final delta = Delta()..insert({'table': _tableModel.toMap()});
128+
widget.controller.replaceText(
129+
offset,
130+
1,
131+
delta,
132+
TextSelection.collapsed(
133+
offset: offset,
134+
),
135+
);
136+
});
137+
}
138+
139+
@override
140+
Widget build(BuildContext context) {
141+
return Material(
142+
child: Container(
143+
decoration: BoxDecoration(
144+
border: Border.all(
145+
color: Theme.of(context).textTheme.bodyMedium?.color ??
146+
Colors.black)),
147+
child: Column(
148+
crossAxisAlignment: CrossAxisAlignment.end,
149+
children: [
150+
IconButton(
151+
icon: const Icon(Icons.more_vert),
152+
onPressed: () async {
153+
final position = renderPosition(context);
154+
await showMenu<TableOperation>(
155+
context: context,
156+
position: position,
157+
items: [
158+
const PopupMenuItem(
159+
value: TableOperation.addColumn,
160+
child: Text('Add column'),
161+
),
162+
const PopupMenuItem(
163+
value: TableOperation.addRow,
164+
child: Text('Add row'),
165+
),
166+
const PopupMenuItem(
167+
value: TableOperation.removeColumn,
168+
child: Text('Delete column'),
169+
),
170+
const PopupMenuItem(
171+
value: TableOperation.removeRow,
172+
child: Text('Delete row'),
173+
),
174+
]).then((value) {
175+
if (value != null) {
176+
if (value == TableOperation.addRow) {
177+
_addRow();
178+
}
179+
if (value == TableOperation.addColumn) {
180+
_addColumn();
181+
}
182+
if (value == TableOperation.removeColumn) {
183+
_removeColumn(_selectedColumnId);
184+
}
185+
if (value == TableOperation.removeRow) {
186+
_removeRow(_selectedRowId);
187+
}
188+
}
189+
});
190+
},
191+
),
192+
const Divider(
193+
color: Colors.white,
194+
height: 1,
195+
),
196+
Table(
197+
border: const TableBorder.symmetric(
198+
inside: BorderSide(color: Colors.white)),
199+
children: _buildTableRows(),
200+
),
201+
],
202+
),
203+
),
204+
);
205+
}
206+
207+
List<TableRow> _buildTableRows() {
208+
final rows = <TableRow>[];
209+
210+
_tableModel.rows.forEach((rowId, rowModel) {
211+
final rowCells = <Widget>[];
212+
final rowKey = rowId;
213+
rowModel.cells.forEach((key, value) {
214+
if (key != 'id') {
215+
final columnId = key;
216+
final data = value;
217+
rowCells.add(TableCellWidget(
218+
cellId: rowKey,
219+
onTap: (node) {
220+
setState(() {
221+
_selectedColumnId = columnId;
222+
_selectedRowId = rowModel.id;
223+
});
224+
},
225+
cellData: data,
226+
onUpdate: (data) => _updateCell(columnId, rowKey, data),
227+
));
228+
}
229+
});
230+
rows.add(TableRow(children: rowCells));
231+
});
232+
return rows;
233+
}
234+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
class TableModel {
2+
TableModel({required this.columns, required this.rows});
3+
4+
factory TableModel.fromMap(Map<String, dynamic> json) {
5+
return TableModel(
6+
columns: (json['columns'] as Map<String, dynamic>).map(
7+
(key, value) => MapEntry(
8+
key,
9+
ColumnModel.fromMap(
10+
value,
11+
),
12+
),
13+
),
14+
rows: (json['rows'] as Map<String, dynamic>).map(
15+
(key, value) => MapEntry(
16+
key,
17+
RowModel.fromMap(
18+
value,
19+
),
20+
),
21+
),
22+
);
23+
}
24+
Map<String, ColumnModel> columns;
25+
Map<String, RowModel> rows;
26+
27+
Map<String, dynamic> toMap() {
28+
return {
29+
'columns': columns.map(
30+
(key, value) => MapEntry(
31+
key,
32+
value.toMap(),
33+
),
34+
),
35+
'rows': rows.map(
36+
(key, value) => MapEntry(
37+
key,
38+
value.toMap(),
39+
),
40+
),
41+
};
42+
}
43+
}
44+
45+
class ColumnModel {
46+
ColumnModel({required this.id, required this.position});
47+
48+
factory ColumnModel.fromMap(Map<String, dynamic> json) {
49+
return ColumnModel(
50+
id: json['id'],
51+
position: json['position'],
52+
);
53+
}
54+
String id;
55+
int position;
56+
57+
Map<String, dynamic> toMap() {
58+
return {
59+
'id': id,
60+
'position': position,
61+
};
62+
}
63+
}
64+
65+
class RowModel {
66+
// Key is column ID, value is cell content
67+
68+
RowModel({required this.id, required this.cells});
69+
70+
factory RowModel.fromMap(Map<String, dynamic> json) {
71+
return RowModel(
72+
id: json['id'],
73+
cells: Map<String, String>.from(json['cells']),
74+
);
75+
}
76+
String id;
77+
Map<String, String> cells;
78+
79+
Map<String, dynamic> toMap() {
80+
return {
81+
'id': id,
82+
'cells': cells,
83+
};
84+
}
85+
}

0 commit comments

Comments
 (0)