Merge branch 'master' of https://github.com/Alspb/flutter-quill into performance_optimization
commit
7af204a17d
135 changed files with 4856 additions and 1036 deletions
@ -0,0 +1 @@ |
||||
blank_issues_enabled: false |
File diff suppressed because one or more lines are too long
@ -1,10 +1,22 @@ |
||||
# Development notes |
||||
|
||||
- When updating the translations or localizations in the app, please take a look at the [Translation](./translation.md) page as it has important notes in order to work, if you also add a feature that adds new localizations then you need to the instructions of it in order for the translations to take effect |
||||
- We use the same package version and `CHANGELOG.md` for all the packages, for more [details](https://github.com/singerdmx/flutter-quill/pull/1878), the process is automated. We have a script that will do the followings: |
||||
1. Generate the `CHANGELOG.md` files by `CHANGELOG_JSON.json` (source of data) and then paste them into all the packages we have (overwrite), you don't need to |
||||
manually change/update any of the mentioned files above, once a new GitHub release published, the CI will take the release notes from the release, pass the info to the |
||||
script, the release notes can be auto-generated by Github using a button, a descirptive PRs title would help but you don't have to since we can change it at anytime. |
||||
2. The script require the new version as an argument, you don't need to run the script manually, when a maintainer create a new tag and publish a new GitHub release, the publish workflow will extract the new version from the tag name, run the script (pass the extracted version as an argument), commit the changes and push them into the repository, the script will update the `version` property for all the packages so the `flutter pub publish` will use the new version for each package correctly. |
||||
- When updating the translations or localizations in the app, please take a look at the [Translation](./translation.md) |
||||
page as it has important notes to work. |
||||
If you also add a feature that adds new localizations, then you need it |
||||
to the instructions of it in order for the translations to take effect |
||||
- We use the same package version and `CHANGELOG.md` for all the packages, for |
||||
more [details](https://github.com/singerdmx/flutter-quill/pull/1878), the process is automated. We have a script that |
||||
will do the following: |
||||
1. Generate the `CHANGELOG.md` files by `CHANGELOG_JSON.json` (source of data) and then paste them into all the |
||||
packages we have (overwrite), you don't need to |
||||
manually change/update any of the mentioned files above, once a new GitHub release published, the CI will take |
||||
the release notes from the release, pass the info to the |
||||
script, the release notes can be auto-generated by GitHub using a button, a descriptive PRs title would help but |
||||
you don't have to since we can change it at anytime. |
||||
2. The script require the new version as an argument, you don't need to run the script manually, when a maintainer |
||||
create a new tag and publish a new GitHub release, the publish workflow will extract the new version from the tag |
||||
name, run the script (pass the extracted version as an argument), commit the changes and push them into the |
||||
repository, the script will update the `version` property for all the packages so the `flutter pub publish` will |
||||
use the new version for each package correctly. |
||||
|
||||
the script will be used the CI and no need to run it manually |
||||
the script will be used the CI and no need to run it manually |
@ -1,55 +0,0 @@ |
||||
# Todo |
||||
|
||||
This is a todo list page that added recently and will be updated soon. |
||||
|
||||
## Table of contents |
||||
- [Todo](#todo) |
||||
- [Table of contents](#table-of-contents) |
||||
- [Flutter Quill](#flutter-quill) |
||||
- [Features](#features) |
||||
- [Improvemenets](#improvemenets) |
||||
- [Bugs](#bugs) |
||||
- [Flutter Quill Extensions](#flutter-quill-extensions) |
||||
- [Features](#features-1) |
||||
- [Improvemenets](#improvemenets-1) |
||||
- [Bugs](#bugs-1) |
||||
|
||||
## Flutter Quill |
||||
|
||||
### Features |
||||
|
||||
- Add support for Text magnification feature, for more [info](https://github.com/singerdmx/flutter-quill/issues/1504) |
||||
- Provide a way to expose quills undo redo stacks, for more [info](https://github.com/singerdmx/flutter-quill/issues/1381) |
||||
- Add callback to the `QuillToolbarColorButton` for custom color picking logic |
||||
|
||||
### Improvemenets |
||||
|
||||
- Improve the Raw Quill Editor, for more [info](https://github.com/singerdmx/flutter-quill/issues/1509) |
||||
- Provide more support to all the platforms |
||||
- Extract the shared properties between `QuillRawEditorConfigurations` and `QuillEditorConfigurations` |
||||
- The todo in the this [commit](https://github.com/singerdmx/flutter-quill/commit/79597ea6425357795c0663588ac079665241f23a) needs to be checked |
||||
- use `maybeOf` and of instead `ofNotNull` in the providers to follow flutter offical convenstion, completly rework the providers and update the build context extensions |
||||
- Add line through to the text when the check point checked is true |
||||
- Change the color of the numbers and dots in ol/ul to match the ones in the item list |
||||
- Fix the bugs of the font family and font size |
||||
- Try to update Quill Html Converter |
||||
- When pasting a HTML text from cliboard by not using the context menu builder, the new logic won't work |
||||
- When selecting all text and paste HTML text, it will not replace the current text, instead it will add a text |
||||
- Add strike-through in checkbox text when the checkpoint is checked |
||||
- No more using of dynamic |
||||
- There is a bug here, the first character is not being formatted when choosing font family or font size and type in the editor |
||||
- Fix the toolbar and the toolbar buttons, rework some of them, for example missing tooltips |
||||
|
||||
### Bugs |
||||
|
||||
Empty for now. |
||||
Please go to the [issues](https://github.com/singerdmx/flutter-quill/issues) |
||||
|
||||
|
||||
## Flutter Quill Extensions |
||||
|
||||
### Features |
||||
|
||||
### Improvemenets |
||||
|
||||
### Bugs |
Binary file not shown.
@ -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 'package:flutter_quill/translations.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; |
||||
} |
||||
|
||||
String _tooltip(BuildContext context) { |
||||
return options.tooltip ?? |
||||
baseButtonExtraOptions(context)?.tooltip ?? |
||||
context.loc.insertTable; |
||||
} |
||||
|
||||
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); |
||||
} |
||||
}, |
||||
); |
||||
} |
||||
} |
@ -1,78 +1,143 @@ |
||||
import 'package:flutter/gestures.dart' show TapGestureRecognizer; |
||||
import 'package:flutter/widgets.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter_quill/flutter_quill.dart' show DefaultStyles; |
||||
import 'package:url_launcher/url_launcher.dart' show launchUrl; |
||||
import 'package:url_launcher/url_launcher_string.dart'; |
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart'; |
||||
import 'package:youtube_player_flutter/youtube_player_flutter.dart'; |
||||
|
||||
import '../../models/config/video/editor/youtube_video_support_mode.dart'; |
||||
import 'video_app.dart'; |
||||
|
||||
class YoutubeVideoApp extends StatefulWidget { |
||||
const YoutubeVideoApp({ |
||||
required this.videoUrl, |
||||
required this.readOnly, |
||||
required this.youtubeVideoSupportMode, |
||||
super.key, |
||||
}); |
||||
|
||||
final String videoUrl; |
||||
final bool readOnly; |
||||
final YoutubeVideoSupportMode youtubeVideoSupportMode; |
||||
|
||||
@override |
||||
YoutubeVideoAppState createState() => YoutubeVideoAppState(); |
||||
} |
||||
|
||||
class YoutubeVideoAppState extends State<YoutubeVideoApp> { |
||||
YoutubePlayerController? _youtubeController; |
||||
YoutubePlayerController? _youtubeIframeController; |
||||
|
||||
/// On some platforms such as desktop, Webview is not supported yet |
||||
/// as a result the youtube video player package is not supported too |
||||
/// this future will be not null and fetch the video url to load it using |
||||
/// [VideoApp] |
||||
Future<String>? _loadYoutubeVideoByDownloadUrlFuture; |
||||
|
||||
/// Null if the video URL is not a YouTube video |
||||
String? get _videoId { |
||||
return YoutubePlayer.convertUrlToId(widget.videoUrl); |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
final videoId = YoutubePlayer.convertUrlToId(widget.videoUrl); |
||||
if (videoId != null) { |
||||
_youtubeController = YoutubePlayerController( |
||||
initialVideoId: videoId, |
||||
flags: const YoutubePlayerFlags( |
||||
autoPlay: false, |
||||
), |
||||
); |
||||
final videoId = _videoId; |
||||
if (videoId == null) { |
||||
return; |
||||
} |
||||
switch (widget.youtubeVideoSupportMode) { |
||||
case YoutubeVideoSupportMode.disabled: |
||||
break; |
||||
case YoutubeVideoSupportMode.iframeView: |
||||
_youtubeIframeController = YoutubePlayerController( |
||||
initialVideoId: videoId, |
||||
flags: const YoutubePlayerFlags( |
||||
autoPlay: false, |
||||
), |
||||
); |
||||
break; |
||||
case YoutubeVideoSupportMode.customPlayerWithDownloadUrl: |
||||
_loadYoutubeVideoByDownloadUrlFuture = |
||||
_loadYoutubeVideoWithVideoPlayerByVideoUrl(); |
||||
break; |
||||
} |
||||
} |
||||
|
||||
Future<String> _loadYoutubeVideoWithVideoPlayerByVideoUrl() async { |
||||
final youtubeExplode = YoutubeExplode(); |
||||
final manifest = |
||||
await youtubeExplode.videos.streamsClient.getManifest(_videoId); |
||||
final streamInfo = manifest.muxed.withHighestBitrate(); |
||||
final videoDownloadUri = streamInfo.url; |
||||
return videoDownloadUri.toString(); |
||||
} |
||||
|
||||
Widget _clickableVideoLinkText({required DefaultStyles defaultStyles}) { |
||||
return RichText( |
||||
text: TextSpan( |
||||
text: widget.videoUrl, |
||||
style: defaultStyles.link, |
||||
recognizer: TapGestureRecognizer() |
||||
..onTap = () => launchUrlString(widget.videoUrl), |
||||
), |
||||
); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final defaultStyles = DefaultStyles.getInstance(context); |
||||
final youtubeController = _youtubeController; |
||||
|
||||
if (youtubeController == null) { |
||||
if (widget.readOnly) { |
||||
return RichText( |
||||
text: TextSpan( |
||||
text: widget.videoUrl, |
||||
style: defaultStyles.link, |
||||
recognizer: TapGestureRecognizer() |
||||
..onTap = () => launchUrl( |
||||
Uri.parse(widget.videoUrl), |
||||
), |
||||
|
||||
switch (widget.youtubeVideoSupportMode) { |
||||
case YoutubeVideoSupportMode.disabled: |
||||
throw UnsupportedError('YouTube video links are not supported'); |
||||
case YoutubeVideoSupportMode.iframeView: |
||||
final youtubeController = _youtubeIframeController; |
||||
|
||||
if (youtubeController == null) { |
||||
if (widget.readOnly) { |
||||
return _clickableVideoLinkText(defaultStyles: defaultStyles); |
||||
} |
||||
|
||||
return RichText( |
||||
text: TextSpan(text: widget.videoUrl, style: defaultStyles.link), |
||||
); |
||||
} |
||||
return YoutubePlayerBuilder( |
||||
player: YoutubePlayer( |
||||
controller: youtubeController, |
||||
showVideoProgressIndicator: true, |
||||
), |
||||
builder: (context, player) { |
||||
return player; |
||||
}, |
||||
); |
||||
case YoutubeVideoSupportMode.customPlayerWithDownloadUrl: |
||||
assert( |
||||
_loadYoutubeVideoByDownloadUrlFuture != null, |
||||
'The load youtube video future should not null for "${widget.youtubeVideoSupportMode}" mode', |
||||
); |
||||
} |
||||
|
||||
return RichText( |
||||
text: TextSpan(text: widget.videoUrl, style: defaultStyles.link), |
||||
); |
||||
return FutureBuilder<String>( |
||||
future: _loadYoutubeVideoByDownloadUrlFuture, |
||||
builder: (context, snapshot) { |
||||
if (snapshot.connectionState == ConnectionState.waiting) { |
||||
return const Center(child: CircularProgressIndicator.adaptive()); |
||||
} |
||||
if (snapshot.hasError) { |
||||
return _clickableVideoLinkText(defaultStyles: defaultStyles); |
||||
} |
||||
return VideoApp( |
||||
videoUrl: snapshot.requireData, |
||||
readOnly: widget.readOnly, |
||||
); |
||||
}, |
||||
); |
||||
} |
||||
|
||||
return YoutubePlayerBuilder( |
||||
player: YoutubePlayer( |
||||
controller: youtubeController, |
||||
showVideoProgressIndicator: true, |
||||
), |
||||
builder: (context, player) { |
||||
return player; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
_youtubeController?.dispose(); |
||||
_youtubeIframeController?.dispose(); |
||||
super.dispose(); |
||||
} |
||||
} |
||||
|
@ -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,19 @@ |
||||
/// Enum represents the different modes for handling YouTube video support. |
||||
enum YoutubeVideoSupportMode { |
||||
/// Disable loading of YouTube videos. |
||||
disabled, |
||||
|
||||
/// Load the video using the official YouTube IFrame API. |
||||
/// See [YouTube IFrame API](https://developers.google.com/youtube/iframe_api_reference) for more details. |
||||
/// |
||||
/// This will use Platform View on native platforms to use WebView |
||||
/// The WebView might not be supported on Desktop and will throw an exception |
||||
/// |
||||
/// See [Flutter InAppWebview Support for Flutter Desktop](https://github.com/pichillilorenzo/flutter_inappwebview/issues/460) |
||||
iframeView, |
||||
|
||||
/// Load the video using a custom video player by fetching the YouTube video URL. |
||||
/// Note: This might violate YouTube's terms of service. |
||||
/// See [YouTube Terms of Service](https://www.youtube.com/static?template=terms) for more details. |
||||
customPlayerWithDownloadUrl, |
||||
} |
@ -0,0 +1,177 @@ |
||||
import 'dart:async' show Completer; |
||||
import 'dart:convert' show utf8; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
// ignore: implementation_imports |
||||
import 'package:flutter_quill/src/services/clipboard/clipboard_service.dart'; |
||||
import 'package:super_clipboard/super_clipboard.dart'; |
||||
|
||||
/// Implementation based on https://pub.dev/packages/super_clipboard |
||||
class SuperClipboardService implements ClipboardService { |
||||
/// Null if the Clipboard API is not supported on this platform |
||||
/// https://pub.dev/packages/super_clipboard#usage |
||||
SystemClipboard? _getSuperClipboard() { |
||||
return SystemClipboard.instance; |
||||
} |
||||
|
||||
SystemClipboard _getSuperClipboardOrThrow() { |
||||
final clipboard = _getSuperClipboard(); |
||||
if (clipboard == null) { |
||||
// To avoid getting this exception, use _canProvide() |
||||
throw UnsupportedError( |
||||
'Clipboard API is not supported on this platform.', |
||||
); |
||||
} |
||||
return clipboard; |
||||
} |
||||
|
||||
Future<bool> _canProvide({required DataFormat format}) async { |
||||
final clipboard = _getSuperClipboard(); |
||||
if (clipboard == null) { |
||||
return false; |
||||
} |
||||
final reader = await clipboard.read(); |
||||
return reader.canProvide(format); |
||||
} |
||||
|
||||
Future<Uint8List> _provideFileAsBytes({ |
||||
required SimpleFileFormat format, |
||||
}) async { |
||||
final clipboard = _getSuperClipboardOrThrow(); |
||||
final reader = await clipboard.read(); |
||||
final completer = Completer<Uint8List>(); |
||||
|
||||
reader.getFile( |
||||
format, |
||||
(file) async { |
||||
final bytes = await file.readAll(); |
||||
completer.complete(bytes); |
||||
}, |
||||
onError: completer.completeError, |
||||
); |
||||
final bytes = await completer.future; |
||||
return bytes; |
||||
} |
||||
|
||||
Future<String> _provideFileAsString({ |
||||
required SimpleFileFormat format, |
||||
}) async { |
||||
final fileBytes = await _provideFileAsBytes(format: format); |
||||
final fileText = utf8.decode(fileBytes); |
||||
return fileText; |
||||
} |
||||
|
||||
/// According to super_clipboard docs, will return `null` if the value |
||||
/// is not available or the data is virtual (macOS and Windows) |
||||
Future<String?> _provideSimpleValueFormatAsString({ |
||||
required SimpleValueFormat<String> format, |
||||
}) async { |
||||
final clipboard = _getSuperClipboardOrThrow(); |
||||
final reader = await clipboard.read(); |
||||
final value = await reader.readValue<String>(format); |
||||
return value; |
||||
} |
||||
|
||||
@override |
||||
Future<bool> canProvideHtmlText() { |
||||
return _canProvide(format: Formats.htmlText); |
||||
} |
||||
|
||||
@override |
||||
Future<String?> getHtmlText() { |
||||
return _provideSimpleValueFormatAsString(format: Formats.htmlText); |
||||
} |
||||
|
||||
@override |
||||
Future<bool> canProvideHtmlTextFromFile() { |
||||
return _canProvide(format: Formats.htmlFile); |
||||
} |
||||
|
||||
@override |
||||
Future<String?> getHtmlTextFromFile() { |
||||
return _provideFileAsString(format: Formats.htmlFile); |
||||
} |
||||
|
||||
@override |
||||
Future<bool> canProvideMarkdownText() async { |
||||
// Formats.markdownText or Formats.mdText does not exist yet in super_clipboard |
||||
return false; |
||||
} |
||||
|
||||
@override |
||||
Future<String?> getMarkdownText() async { |
||||
// Formats.markdownText or Formats.mdText does not exist yet in super_clipboard |
||||
throw UnsupportedError( |
||||
'SuperClipboardService does not support retrieving image files.', |
||||
); |
||||
} |
||||
|
||||
@override |
||||
Future<bool> canProvideMarkdownTextFromFile() async { |
||||
// Formats.md is for markdown files |
||||
return _canProvide(format: Formats.md); |
||||
} |
||||
|
||||
@override |
||||
Future<String?> getMarkdownTextFromFile() async { |
||||
// Formats.md is for markdown files |
||||
return _provideFileAsString(format: Formats.md); |
||||
} |
||||
|
||||
@override |
||||
Future<bool> canProvidePlainText() { |
||||
return _canProvide(format: Formats.plainText); |
||||
} |
||||
|
||||
@override |
||||
Future<String?> getPlainText() { |
||||
return _provideSimpleValueFormatAsString(format: Formats.plainText); |
||||
} |
||||
|
||||
/// This will need to be updated if [getImageFileAsBytes] updated. |
||||
/// Notice that even if the copied image is JPEG, it still can be provided |
||||
/// as PNG, will handle JPEG check in case this info is incorrect. |
||||
@override |
||||
Future<bool> canProvideImageFile() async { |
||||
final canProvidePngFile = await _canProvide(format: Formats.png); |
||||
if (canProvidePngFile) { |
||||
return true; |
||||
} |
||||
final canProvideJpegFile = await _canProvide(format: Formats.jpeg); |
||||
if (canProvideJpegFile) { |
||||
return true; |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
/// This will need to be updated if [canProvideImageFile] updated. |
||||
@override |
||||
Future<Uint8List> getImageFileAsBytes() async { |
||||
final canProvidePngFile = await _canProvide(format: Formats.png); |
||||
if (canProvidePngFile) { |
||||
return _provideFileAsBytes(format: Formats.png); |
||||
} |
||||
return _provideFileAsBytes(format: Formats.jpeg); |
||||
} |
||||
|
||||
@override |
||||
Future<bool> canProvideGifFile() { |
||||
return _canProvide(format: Formats.gif); |
||||
} |
||||
|
||||
@override |
||||
Future<Uint8List> getGifFileAsBytes() { |
||||
return _provideFileAsBytes(format: Formats.gif); |
||||
} |
||||
|
||||
@override |
||||
Future<bool> canPaste() async { |
||||
final clipboard = _getSuperClipboard(); |
||||
if (clipboard == null) { |
||||
return false; |
||||
} |
||||
final reader = await clipboard.read(); |
||||
final availablePlatformFormats = reader.platformFormats; |
||||
return availablePlatformFormats.isNotEmpty; |
||||
} |
||||
} |
@ -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; |
||||
} |
@ -1,8 +1,14 @@ |
||||
class QuillControllerConfigurations { |
||||
const QuillControllerConfigurations({this.onClipboardPaste}); |
||||
const QuillControllerConfigurations( |
||||
{this.onClipboardPaste, this.requireScriptFontFeatures = false}); |
||||
|
||||
/// Callback when the user pastes and data has not already been processed |
||||
/// |
||||
/// Return true if the paste operation was handled |
||||
final Future<bool> Function()? onClipboardPaste; |
||||
|
||||
/// Render subscript and superscript text using Open Type FontFeatures |
||||
/// |
||||
/// Default is false to use built-in script rendering that is independent of font capabilities |
||||
final bool requireScriptFontFeatures; |
||||
} |
||||
|
@ -1,48 +1,49 @@ |
||||
import 'package:html2md/html2md.dart' as html2md; |
||||
import 'package:markdown/markdown.dart' as md; |
||||
import 'package:meta/meta.dart'; |
||||
|
||||
import '../../../markdown_quill.dart'; |
||||
import '../../../quill_delta.dart'; |
||||
import '../../utils/delta_x_utils.dart'; |
||||
|
||||
@immutable |
||||
@experimental |
||||
class DeltaX { |
||||
const DeltaX._(); |
||||
|
||||
/// Convert Markdown text to [Delta] |
||||
/// |
||||
/// This api is **experimental** and designed to be used **internally** and shouldn't |
||||
/// used for **production applications**. |
||||
@experimental |
||||
static Delta fromMarkdown(String markdownText) { |
||||
final mdDocument = md.Document( |
||||
encodeHtml: false, |
||||
inlineSyntaxes: [UnderlineSyntax(), VideoSyntax()], |
||||
); |
||||
final mdToDelta = MarkdownToDelta(markdownDocument: mdDocument); |
||||
return mdToDelta.convert(markdownText); |
||||
} |
||||
|
||||
/// Convert the HTML Raw string to [Delta] |
||||
/// |
||||
/// It will run using the following steps: |
||||
/// |
||||
/// 1. Convert the html to markdown string using `html2md` package |
||||
/// 2. Convert the markdown string to quill delta json string |
||||
/// 3. Decode the delta json string to [Delta] |
||||
/// 2. Convert the markdown string to [Delta] using [fromMarkdown] |
||||
/// |
||||
/// for more [info](https://github.com/singerdmx/flutter-quill/issues/1100) |
||||
/// |
||||
/// Please notice that this api is designed to be used internally and shouldn't |
||||
/// used for real world applications |
||||
/// This api is **experimental** and designed to be used **internally** and shouldn't |
||||
/// used for **production applications**. |
||||
/// |
||||
@experimental |
||||
static Delta fromHtml(String html) { |
||||
final markdown = html2md |
||||
.convert( |
||||
html, |
||||
) |
||||
.replaceAll('unsafe:', ''); |
||||
|
||||
final mdDocument = md.Document(encodeHtml: false); |
||||
|
||||
final mdToDelta = MarkdownToDelta(markdownDocument: mdDocument); |
||||
|
||||
return mdToDelta.convert(markdown); |
||||
|
||||
// final deltaJsonString = markdownToDelta(markdown); |
||||
// final deltaJson = jsonDecode(deltaJsonString); |
||||
// if (deltaJson is! List) { |
||||
// throw ArgumentError( |
||||
// 'The delta json string should be of type list when jsonDecode() it', |
||||
// ); |
||||
// } |
||||
// return Delta.fromJson( |
||||
// deltaJson, |
||||
// ); |
||||
static Delta fromHtml(String htmlText) { |
||||
final markdownText = html2md.convert( |
||||
htmlText, |
||||
rules: [underlineRule, videoRule], |
||||
styleOptions: {'emDelimiter': '*'}, |
||||
).replaceAll( |
||||
'unsafe:', |
||||
'', |
||||
); |
||||
return fromMarkdown(markdownText); |
||||
} |
||||
} |
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue