feat: support uploading images from local files (#232)

This commit is contained in:
Mukund Tandon 2023-06-28 17:29:59 +05:30 • committed by GitHub
commit ed75bf61df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 676 additions and 340 deletions

View file

@ -0,0 +1,25 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
Image imageFromBase64String(String base64String, {double? width}) {
return Image.memory(
base64Decode(base64String),
width: width,
);
}
Uint8List dataFromBase64String(String base64String) {
return base64Decode(base64String);
}
String base64String(Uint8List data) {
return base64Encode(data);
}
Future<String> base64StringFromImage(String imagePath) async {
final file = File(imagePath); //convert Path to File
final imageBytes = await file.readAsBytes(); //convert to bytes
return base64.encode(imageBytes);
}

View file

@ -2,7 +2,23 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'image_block_widget.dart'; import 'resizable_image.dart';
enum ImageSourceType {
network,
file;
static ImageSourceType fromString(String value) {
switch (value) {
case 'network':
return ImageSourceType.network;
case 'file':
return ImageSourceType.file;
default:
return ImageSourceType.network; // compatible with old version
}
}
}
class ImageBlockKeys { class ImageBlockKeys {
ImageBlockKeys._(); ImageBlockKeys._();
@ -30,10 +46,23 @@ class ImageBlockKeys {
/// ///
/// The value is a double. /// The value is a double.
static const String height = 'height'; static const String height = 'height';
/// The image source type of a image block.
///
/// The value is a String.
/// network, file
static const String imageSourceType = 'imageSourceType';
/// The image content of a image block.
///
/// base64 image content
static const String content = 'content';
} }
Node imageNode({ Node imageNode({
required String url, required ImageSourceType imageSourceType,
String? content,
String? url,
String align = 'center', String align = 'center',
double? height, double? height,
double? width, double? width,
@ -42,9 +71,11 @@ Node imageNode({
type: ImageBlockKeys.type, type: ImageBlockKeys.type,
attributes: { attributes: {
ImageBlockKeys.url: url, ImageBlockKeys.url: url,
ImageBlockKeys.content: content,
ImageBlockKeys.align: align, ImageBlockKeys.align: align,
ImageBlockKeys.height: height, ImageBlockKeys.height: height,
ImageBlockKeys.width: width, ImageBlockKeys.width: width,
ImageBlockKeys.imageSourceType: imageSourceType.name,
}, },
); );
} }
@ -73,10 +104,7 @@ class ImageBlockComponentBuilder extends BlockComponentBuilder {
} }
@override @override
bool validate(Node node) => bool validate(Node node) => node.delta == null && node.children.isEmpty;
node.delta == null &&
node.children.isEmpty &&
node.attributes[ImageBlockKeys.url] is String;
} }
class ImageBlockComponentWidget extends BlockComponentStatefulWidget { class ImageBlockComponentWidget extends BlockComponentStatefulWidget {
@ -93,7 +121,10 @@ class ImageBlockComponentWidget extends BlockComponentStatefulWidget {
_ImageBlockComponentWidgetState(); _ImageBlockComponentWidgetState();
} }
class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget> { class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
with SelectableMixin {
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
late final editorState = Provider.of<EditorState>(context, listen: false); late final editorState = Provider.of<EditorState>(context, listen: false);
@override @override
@ -101,15 +132,23 @@ class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget> {
final node = widget.node; final node = widget.node;
final attributes = node.attributes; final attributes = node.attributes;
final src = attributes[ImageBlockKeys.url]; final src = attributes[ImageBlockKeys.url];
final align = attributes[ImageBlockKeys.align] ?? 'center'; final content = attributes[ImageBlockKeys.content];
final width = attributes[ImageBlockKeys.width]?.toDouble(); final alignment = AlignmentExtension.fromString(
attributes[ImageBlockKeys.align] ?? 'center',
);
final width = attributes[ImageBlockKeys.width]?.toDouble() ??
MediaQuery.of(context).size.width;
final imageSourceType = ImageSourceType.fromString(
attributes[ImageBlockKeys.imageSourceType],
);
Widget child = ImageNodeWidget( Widget child = ResizableImage(
node: node,
src: src, src: src,
content: content,
width: width, width: width,
editable: editorState.editable, editable: editorState.editable,
alignment: _textToAlignment(align), alignment: alignment,
type: imageSourceType,
onResize: (width) { onResize: (width) {
final transaction = editorState.transaction final transaction = editorState.transaction
..updateNode(node, { ..updateNode(node, {
@ -130,12 +169,51 @@ class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget> {
return child; return child;
} }
Alignment _textToAlignment(String text) { @override
if (text == 'left') { Position start() => Position(path: widget.node.path, offset: 0);
return Alignment.centerLeft;
} else if (text == 'right') { @override
return Alignment.centerRight; Position end() => Position(path: widget.node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Rect? getCursorRectInPosition(Position position) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}
@override
List<Rect> getRectsInSelection(Selection selection) =>
[Offset.zero & _renderBox.size];
@override
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
path: widget.node.path,
startOffset: 0,
endOffset: 1,
);
@override
Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
}
extension AlignmentExtension on Alignment {
static Alignment fromString(String name) {
switch (name) {
case 'left':
return Alignment.centerLeft;
case 'right':
return Alignment.centerRight;
default:
return Alignment.center;
} }
return Alignment.center;
} }
} }

View file

@ -1,278 +0,0 @@
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:flutter/material.dart';
class ImageNodeWidget extends StatefulWidget {
const ImageNodeWidget({
Key? key,
required this.node,
required this.src,
this.width,
required this.alignment,
required this.editable,
required this.onResize,
}) : super(key: key);
final Node node;
final String src;
final double? width;
final Alignment alignment;
final bool editable;
final void Function(double width) onResize;
@override
State<ImageNodeWidget> createState() => ImageNodeWidgetState();
}
class ImageNodeWidgetState extends State<ImageNodeWidget> with SelectableMixin {
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
final _imageKey = GlobalKey();
double? _imageWidth;
double _initial = 0;
double _distance = 0;
@visibleForTesting
bool onFocus = false;
ImageStream? _imageStream;
late ImageStreamListener _imageStreamListener;
@override
void initState() {
super.initState();
_imageWidth = widget.width;
_imageStreamListener = ImageStreamListener(
(image, _) {
_imageWidth = _imageKey.currentContext
?.findRenderObject()
?.unwrapOrNull<RenderBox>()
?.size
.width;
},
);
}
@override
void dispose() {
_imageStream?.removeListener(_imageStreamListener);
super.dispose();
}
@override
Widget build(BuildContext context) {
// only support network image.
return Container(
key: _imageKey,
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: _buildNetworkImage(context),
);
}
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Position start() {
return Position(path: widget.node.path, offset: 0);
}
@override
Position end() {
return start();
}
@override
Position getPositionInOffset(Offset start) {
return end();
}
@override
Rect? getCursorRectInPosition(Position position) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}
@override
List<Rect> getRectsInSelection(Selection selection) {
final renderBox = context.findRenderObject() as RenderBox;
return [Offset.zero & renderBox.size];
}
@override
Selection getSelectionInRange(Offset start, Offset end) {
if (start <= end) {
return Selection(start: this.start(), end: this.end());
} else {
return Selection(start: this.end(), end: this.start());
}
}
@override
Offset localToGlobal(Offset offset) {
final renderBox = context.findRenderObject() as RenderBox;
return renderBox.localToGlobal(offset);
}
Widget _buildNetworkImage(BuildContext context) {
return Align(
alignment: widget.alignment,
child: MouseRegion(
onEnter: (event) => setState(() {
onFocus = true;
}),
onExit: (event) => setState(() {
onFocus = false;
}),
child: _buildResizableImage(context),
),
);
}
Widget _buildResizableImage(BuildContext context) {
final networkImage = Image.network(
widget.src,
width: _imageWidth == null ? null : _imageWidth! - _distance,
gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null ||
loadingProgress.cumulativeBytesLoaded ==
loadingProgress.expectedTotalBytes) {
return child;
}
return _buildLoading(context);
},
errorBuilder: (context, error, stackTrace) => _buildError(context),
);
if (_imageWidth == null) {
_imageStream = networkImage.image.resolve(const ImageConfiguration())
..addListener(_imageStreamListener);
}
return Stack(
children: [
networkImage,
if (widget.editable) ...[
_buildEdgeGesture(
context,
top: 0,
left: 0,
bottom: 0,
width: 5,
onUpdate: (distance) {
setState(() {
_distance = distance;
});
},
),
_buildEdgeGesture(
context,
top: 0,
right: 0,
bottom: 0,
width: 5,
onUpdate: (distance) {
setState(() {
_distance = -distance;
});
},
),
],
],
);
}
Widget _buildLoading(BuildContext context) {
return SizedBox(
height: 150,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox.fromSize(
size: const Size(18, 18),
child: const CircularProgressIndicator(),
),
SizedBox.fromSize(
size: const Size(10, 10),
),
const Text('Loading'),
],
),
);
}
Widget _buildError(BuildContext context) {
return Container(
height: 100,
width: _imageWidth,
alignment: Alignment.center,
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
border: Border.all(width: 1, color: Colors.black),
),
child: const Text('Could not load the image'),
);
}
Widget _buildEdgeGesture(
BuildContext context, {
double? top,
double? left,
double? right,
double? bottom,
double? width,
void Function(double distance)? onUpdate,
}) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
width: width,
child: GestureDetector(
onHorizontalDragStart: (details) {
_initial = details.globalPosition.dx;
},
onHorizontalDragUpdate: (details) {
if (onUpdate != null) {
onUpdate(details.globalPosition.dx - _initial);
}
},
onHorizontalDragEnd: (details) {
_imageWidth = _imageWidth! - _distance;
_initial = 0;
_distance = 0;
widget.onResize(_imageWidth!);
},
child: MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: onFocus
? Center(
child: Container(
height: 40,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
borderRadius: const BorderRadius.all(
Radius.circular(5.0),
),
),
),
)
: null,
),
),
);
}
}

View file

@ -1,5 +1,15 @@
import 'dart:io';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/block_component/image_block_component/base64_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../util/file_picker/file_picker_impl.dart';
import 'package:file_picker/file_picker.dart' as fp;
enum ImageFromFileStatus {
notSelected,
selected,
}
void showImageMenu( void showImageMenu(
OverlayState container, OverlayState container,
@ -12,8 +22,11 @@ void showImageMenu(
late final OverlayEntry imageMenuEntry; late final OverlayEntry imageMenuEntry;
void insertImage(String text) { void insertImage(
editorState.insertImageNode(text); String src, {
ImageSourceType imageSourceType = ImageSourceType.network,
}) {
editorState.insertImageNode(src, imageSourceType);
menuService.dismiss(); menuService.dismiss();
imageMenuEntry.remove(); imageMenuEntry.remove();
keepEditorFocusNotifier.value -= 1; keepEditorFocusNotifier.value -= 1;
@ -50,15 +63,20 @@ class UploadImageMenu extends StatefulWidget {
final Color headerColor; final Color headerColor;
final double width; final double width;
final void Function(String text) onSubmitted; final void Function(String text) onSubmitted;
final void Function(String text) onUpload; final void Function(String text, {ImageSourceType imageSourceType}) onUpload;
@override @override
State<UploadImageMenu> createState() => _UploadImageMenuState(); State<UploadImageMenu> createState() => _UploadImageMenuState();
} }
class _UploadImageMenuState extends State<UploadImageMenu> { class _UploadImageMenuState extends State<UploadImageMenu> {
static const allowedExtensions = ['jpg', 'png', 'jpeg'];
final _textEditingController = TextEditingController(); final _textEditingController = TextEditingController();
final _focusNode = FocusNode(); final _focusNode = FocusNode();
final _filePicker = FilePicker();
String? _localImagePath;
@override @override
void initState() { void initState() {
@ -76,6 +94,7 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: widget.width, width: widget.width,
height: 350,
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.backgroundColor, color: widget.backgroundColor,
@ -88,15 +107,37 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
], ],
// borderRadius: BorderRadius.circular(6.0), // borderRadius: BorderRadius.circular(6.0),
), ),
child: Column( child: DefaultTabController(
crossAxisAlignment: CrossAxisAlignment.start, length: 2,
children: [ child: Column(
_buildHeader(context), children: [
const SizedBox(height: 16.0), const Align(
_buildInput(), alignment: Alignment.centerLeft,
const SizedBox(height: 18.0), child: SizedBox(
_buildUploadButton(context), width: 300,
], child: TabBar(
tabs: [
Tab(text: 'Upload Image'),
Tab(text: 'URL Image'),
],
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Color(0xff00BCF0),
dividerColor: Colors.transparent,
),
),
),
Expanded(
child: TabBarView(
physics: const NeverScrollableScrollPhysics(),
children: [
_buildFileTab(context),
_buildUrlTab(context),
],
),
),
],
),
), ),
); );
} }
@ -142,7 +183,10 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
); );
} }
Widget _buildUploadButton(BuildContext context) { Widget _buildUploadButton(
BuildContext context,
ImageSourceType imageSourceType,
) {
return SizedBox( return SizedBox(
width: 170, width: 170,
height: 48, height: 48,
@ -155,7 +199,21 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
), ),
), ),
), ),
onPressed: () => widget.onUpload(_textEditingController.text), onPressed: () async {
if (imageSourceType == ImageSourceType.network) {
widget.onUpload(
_textEditingController.text,
imageSourceType: imageSourceType,
);
} else if (imageSourceType == ImageSourceType.file &&
_localImagePath != null) {
final content = await base64StringFromImage(_localImagePath!);
widget.onUpload(
content,
imageSourceType: imageSourceType,
);
}
},
child: const Text( child: const Text(
'Upload', 'Upload',
style: TextStyle(color: Colors.white, fontSize: 14.0), style: TextStyle(color: Colors.white, fontSize: 14.0),
@ -163,10 +221,98 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
), ),
); );
} }
Widget _buildUrlTab(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(context),
const SizedBox(height: 16.0),
_buildInput(),
const SizedBox(height: 18.0),
_buildUploadButton(
context,
ImageSourceType.network,
),
],
);
}
Widget _buildFileTab(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16.0),
_buildFileUploadContainer(context),
const SizedBox(height: 18.0),
Align(
alignment: Alignment.centerRight,
child: _buildUploadButton(
context,
ImageSourceType.file,
),
)
],
);
}
Widget _buildFileUploadContainer(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: () async {
final result = await _filePicker.pickFiles(
dialogTitle: 'Select an image',
allowMultiple: false,
type: fp.FileType.image,
allowedExtensions: allowedExtensions,
);
if (result != null && result.files.isNotEmpty) {
setState(() {
_localImagePath = result.files.first.path;
});
}
},
child: Container(
height: 80,
margin: const EdgeInsets.all(10.0),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(12.0),
),
child: _localImagePath != null
? Align(
alignment: Alignment.center,
child: Image.file(
File(
_localImagePath!,
),
fit: BoxFit.cover,
),
)
: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.image, size: 48.0, color: Colors.grey),
SizedBox(height: 8.0),
Text(
'Pick a file',
style: TextStyle(fontSize: 14.0, color: Colors.grey),
),
],
),
),
),
),
);
}
} }
extension on EditorState { extension on EditorState {
Future<void> insertImageNode(String src) async { Future<void> insertImageNode(
String src,
ImageSourceType imageSourceType,
) async {
final selection = this.selection; final selection = this.selection;
if (selection == null || !selection.isCollapsed) { if (selection == null || !selection.isCollapsed) {
return; return;
@ -177,12 +323,23 @@ extension on EditorState {
} }
final transaction = this.transaction; final transaction = this.transaction;
// if the current node is empty paragraph, replace it with image node // if the current node is empty paragraph, replace it with image node
if (node.type == 'paragraph' && (node.delta?.isEmpty ?? false)) { if (node.type == ParagraphBlockKeys.type &&
(node.delta?.isEmpty ?? false)) {
transaction transaction
..insertNode(node.path, imageNode(url: src)) ..insertNode(
node.path,
imageNode(
url: imageSourceType == ImageSourceType.network ? src : null,
content: imageSourceType == ImageSourceType.file ? src : null,
imageSourceType: imageSourceType,
),
)
..deleteNode(node); ..deleteNode(node);
} else { } else {
transaction.insertNode(node.path.next, imageNode(url: src)); transaction.insertNode(
node.path.next,
imageNode(url: src, imageSourceType: imageSourceType),
);
} }
return apply(transaction); return apply(transaction);

View file

@ -0,0 +1,206 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/block_component/image_block_component/base64_image.dart';
import 'package:flutter/material.dart';
class ResizableImage extends StatefulWidget {
const ResizableImage({
super.key,
required this.alignment,
required this.editable,
required this.onResize,
required this.type,
required this.width,
this.src,
this.content,
});
final String? src;
final String? content;
final double width;
final Alignment alignment;
final bool editable;
final ImageSourceType type;
final void Function(double width) onResize;
@override
State<ResizableImage> createState() => _ResizableImageState();
}
class _ResizableImageState extends State<ResizableImage> {
late double imageWidth;
double initialOffset = 0;
double moveDistance = 0;
Image? _cacheImage;
@visibleForTesting
bool onFocus = false;
@override
void initState() {
super.initState();
imageWidth = widget.width;
}
@override
Widget build(BuildContext context) {
return Align(
alignment: widget.alignment,
child: SizedBox(
width: imageWidth - moveDistance,
child: MouseRegion(
onEnter: (event) => setState(() {
onFocus = true;
}),
onExit: (event) => setState(() {
onFocus = false;
}),
child: _buildResizableImage(context),
),
),
);
}
Widget _buildResizableImage(BuildContext context) {
Widget child;
if (widget.type == ImageSourceType.network && widget.src != null) {
_cacheImage ??= Image.network(
widget.src!,
width: widget.width,
gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null ||
loadingProgress.cumulativeBytesLoaded ==
loadingProgress.expectedTotalBytes) {
return child;
}
return _buildLoading(context);
},
errorBuilder: (context, error, stackTrace) => _buildError(context),
);
child = _cacheImage!;
} else if (widget.type == ImageSourceType.file && widget.content != null) {
_cacheImage ??= imageFromBase64String(widget.content!);
child = _cacheImage!;
} else {
child = _buildError(context);
}
return Stack(
children: [
child,
if (widget.editable) ...[
_buildEdgeGesture(
context,
top: 0,
left: 0,
bottom: 0,
width: 5,
onUpdate: (distance) {
setState(() {
moveDistance = distance;
});
},
),
_buildEdgeGesture(
context,
top: 0,
right: 0,
bottom: 0,
width: 5,
onUpdate: (distance) {
setState(() {
moveDistance = -distance;
});
},
),
],
],
);
}
Widget _buildLoading(BuildContext context) {
return SizedBox(
height: 150,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox.fromSize(
size: const Size(18, 18),
child: const CircularProgressIndicator(),
),
SizedBox.fromSize(
size: const Size(10, 10),
),
const Text('Loading'),
],
),
);
}
Widget _buildError(BuildContext context) {
return Container(
height: 100,
width: imageWidth,
alignment: Alignment.center,
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
border: Border.all(width: 1, color: Colors.black),
),
child: const Text('Could not load the image'),
);
}
Widget _buildEdgeGesture(
BuildContext context, {
double? top,
double? left,
double? right,
double? bottom,
double? width,
void Function(double distance)? onUpdate,
}) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
width: width,
child: GestureDetector(
onHorizontalDragStart: (details) {
initialOffset = details.globalPosition.dx;
},
onHorizontalDragUpdate: (details) {
if (onUpdate != null) {
onUpdate((details.globalPosition.dx - initialOffset) * 2.0);
}
},
onHorizontalDragEnd: (details) {
imageWidth = imageWidth - moveDistance;
initialOffset = 0;
moveDistance = 0;
widget.onResize(imageWidth);
},
child: MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: onFocus
? Center(
child: Container(
height: 40,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
borderRadius: const BorderRadius.all(
Radius.circular(5.0),
),
),
),
)
: null,
),
),
);
}
}

View file

@ -0,0 +1,56 @@
import 'package:appflowy_editor/src/editor/util/file_picker/file_picker_service.dart';
import 'package:file_picker/file_picker.dart' as fp;
class FilePicker implements FilePickerService {
@override
Future<String?> getDirectoryPath({String? title}) {
return fp.FilePicker.platform.getDirectoryPath();
}
@override
Future<FilePickerResult?> pickFiles({
String? dialogTitle,
String? initialDirectory,
fp.FileType type = fp.FileType.any,
List<String>? allowedExtensions,
Function(fp.FilePickerStatus p1)? onFileLoading,
bool allowCompression = true,
bool allowMultiple = false,
bool withData = false,
bool withReadStream = false,
bool lockParentWindow = false,
}) async {
final result = await fp.FilePicker.platform.pickFiles(
dialogTitle: dialogTitle,
initialDirectory: initialDirectory,
type: type,
allowedExtensions: allowedExtensions,
onFileLoading: onFileLoading,
allowCompression: allowCompression,
allowMultiple: allowMultiple,
withData: withData,
withReadStream: withReadStream,
lockParentWindow: lockParentWindow,
);
return FilePickerResult(result?.files ?? []);
}
@override
Future<String?> saveFile({
String? dialogTitle,
String? fileName,
String? initialDirectory,
fp.FileType type = fp.FileType.any,
List<String>? allowedExtensions,
bool lockParentWindow = false,
}) {
return fp.FilePicker.platform.saveFile(
dialogTitle: dialogTitle,
fileName: fileName,
initialDirectory: initialDirectory,
type: type,
allowedExtensions: allowedExtensions,
lockParentWindow: lockParentWindow,
);
}
}

View file

@ -0,0 +1,40 @@
import 'package:file_picker/file_picker.dart';
class FilePickerResult {
const FilePickerResult(this.files);
/// Picked files.
final List<PlatformFile> files;
}
/// Abstract file picker as a service to implement dependency injection.
abstract class FilePickerService {
Future<String?> getDirectoryPath({
String? title,
}) async =>
throw UnimplementedError('getDirectoryPath() has not been implemented.');
Future<FilePickerResult?> pickFiles({
String? dialogTitle,
String? initialDirectory,
FileType type = FileType.any,
List<String>? allowedExtensions,
Function(FilePickerStatus)? onFileLoading,
bool allowCompression = true,
bool allowMultiple = false,
bool withData = false,
bool withReadStream = false,
bool lockParentWindow = false,
}) async =>
throw UnimplementedError('pickFiles() has not been implemented.');
Future<String?> saveFile({
String? dialogTitle,
String? fileName,
String? initialDirectory,
FileType type = FileType.any,
List<String>? allowedExtensions,
bool lockParentWindow = false,
}) async =>
throw UnimplementedError('saveFile() has not been implemented.');
}

View file

@ -321,9 +321,14 @@ class HTMLToNodesConverter {
} }
Node _handleImage(html.Element element) { Node _handleImage(html.Element element) {
final src = element.attributes['src'] ?? ''; final src = element.attributes['src'];
if (src == null || src.isEmpty || !src.startsWith('http')) {
return paragraphNode(); // return empty paragraph
}
// only support network image
return imageNode( return imageNode(
url: src, url: src,
imageSourceType: ImageSourceType.network,
); );
} }

View file

@ -188,9 +188,14 @@ class DocumentHTMLDecoder extends Converter<String, Document> {
} }
Node _parseImageElement(dom.Element element) { Node _parseImageElement(dom.Element element) {
final src = element.attributes['src'] ?? ''; final src = element.attributes['src'];
if (src == null || src.isEmpty || !src.startsWith('http')) {
return paragraphNode(); // return empty paragraph
}
// only support network image
return imageNode( return imageNode(
url: src, url: src,
imageSourceType: ImageSourceType.network,
); );
} }

View file

@ -34,7 +34,7 @@ dependencies:
collection: ^1.17.0 collection: ^1.17.0
nanoid: ^1.0.0 nanoid: ^1.0.0
visibility_detector: ^0.4.0+2 visibility_detector: ^0.4.0+2
file_picker: ^5.3.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

View file

@ -1,4 +1,3 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
@ -14,7 +13,7 @@ void main() async {
expect(result.toJson(), example); expect(result.toJson(), example);
}); });
test('nested parser document', () async { test('nested parser document', () async {
final result = DocumentHTMLDecoder().convert(nestedhtml); final result = DocumentHTMLDecoder().convert(nestedHTML);
expect(result.toJson(), nestedDelta); expect(result.toJson(), nestedDelta);
}); });
@ -294,8 +293,8 @@ const example = {
] ]
} }
}; };
const nestedhtml = const nestedHTML =
'''<h1>Welcome to the playground</h1><blockquote>In case you were wondering what the black box at the bottom is – it\'s the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting. The playground is a demo environment built with <code>@lexical/react</code>. Try typing in <strong>some text</strong> with <i>different</i> formats.</blockquote><p>\t</p><img src="https://richtexteditor.com/images/editor-image.png"><p>Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!</p><p></p><p>If you\'d like to find out more about Lexical, you can:</p><ul><li>Visit the <a href="https://lexical.dev/">Lexical website</a> for documentation and more information.</li><li>\t<span><img src="https://richtexteditor.com/images/editor-image.png"></span></li><li>Check out the code on our <a href="https://github.com/facebook/lexical">GitHub repository</a>.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li><li>Join our <a href="https://discord.com/invite/KmG4wQnnD9">Discord Server</a> and chat with the team.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li></ul><p>Lastly, we\'re constantly adding cool new features to this playground. So make sure you check back here when you next get a chance 🙂.</p><p></p>'''; '''<h1>Welcome to the playground</h1><blockquote>In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting. The playground is a demo environment built with <code>@lexical/react</code>. Try typing in <strong>some text</strong> with <i>different</i> formats.</blockquote><p>\t</p><img src="https://richtexteditor.com/images/editor-image.png"><p>Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!</p><p></p><p>If you'd like to find out more about Lexical, you can:</p><ul><li>Visit the <a href="https://lexical.dev/">Lexical website</a> for documentation and more information.</li><li>\t<span><img src="https://richtexteditor.com/images/editor-image.png"></span></li><li>Check out the code on our <a href="https://github.com/facebook/lexical">GitHub repository</a>.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li><li>Join our <a href="https://discord.com/invite/KmG4wQnnD9">Discord Server</a> and chat with the team.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li></ul><p>Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance 🙂.</p><p></p>''';
const nestedDelta = { const nestedDelta = {
'document': { 'document': {
'type': 'page', 'type': 'page',
@ -347,9 +346,11 @@ const nestedDelta = {
'type': 'image', 'type': 'image',
'data': { 'data': {
'url': 'https://richtexteditor.com/images/editor-image.png', 'url': 'https://richtexteditor.com/images/editor-image.png',
'content': null,
'align': 'center', 'align': 'center',
'height': null, 'height': null,
'width': null 'width': null,
'imageSourceType': 'network'
} }
}, },
{ {
@ -398,9 +399,11 @@ const nestedDelta = {
'type': 'image', 'type': 'image',
'data': { 'data': {
'url': 'https://richtexteditor.com/images/editor-image.png', 'url': 'https://richtexteditor.com/images/editor-image.png',
'content': null,
'align': 'center', 'align': 'center',
'height': null, 'height': null,
'width': null 'width': null,
'imageSourceType': 'network'
} }
} }
], ],

View file

@ -295,7 +295,7 @@ const delta = {
} }
}; };
const nestedhtml = const nestedhtml =
'''<h1>Welcome to the playground</h1><blockquote>In case you were wondering what the black box at the bottom is – it\'s the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting. The playground is a demo environment built with <code>@lexical/react</code>. Try typing in <strong>some text</strong> with <i>different</i> formats.</blockquote><p>\t</p><img src="https://richtexteditor.com/images/editor-image.png"><p>Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!</p><p></p><p>If you\'d like to find out more about Lexical, you can:</p><ul><li>Visit the <a href="https://lexical.dev/">Lexical website</a> for documentation and more information.</li><li>\t<span><img src="https://richtexteditor.com/images/editor-image.png"></span></li><li>Check out the code on our <a href="https://github.com/facebook/lexical">GitHub repository</a>.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li><li>Join our <a href="https://discord.com/invite/KmG4wQnnD9">Discord Server</a> and chat with the team.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li></ul><p>Lastly, we\'re constantly adding cool new features to this playground. So make sure you check back here when you next get a chance 🙂.</p><p></p>'''; '''<h1>Welcome to the playground</h1><blockquote>In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting. The playground is a demo environment built with <code>@lexical/react</code>. Try typing in <strong>some text</strong> with <i>different</i> formats.</blockquote><p>\t</p><img src="https://richtexteditor.com/images/editor-image.png"><p>Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!</p><p></p><p>If you'd like to find out more about Lexical, you can:</p><ul><li>Visit the <a href="https://lexical.dev/">Lexical website</a> for documentation and more information.</li><li>\t<span><img src="https://richtexteditor.com/images/editor-image.png"></span></li><li>Check out the code on our <a href="https://github.com/facebook/lexical">GitHub repository</a>.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li><li>Join our <a href="https://discord.com/invite/KmG4wQnnD9">Discord Server</a> and chat with the team.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li></ul><p>Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance 🙂.</p><p></p>''';
const nestedDelta = { const nestedDelta = {
'document': { 'document': {
'type': 'page', 'type': 'page',
@ -349,7 +349,8 @@ const nestedDelta = {
'url': 'https://richtexteditor.com/images/editor-image.png', 'url': 'https://richtexteditor.com/images/editor-image.png',
'align': 'center', 'align': 'center',
'height': null, 'height': null,
'width': null 'width': null,
'imageSourceType': 'network'
} }
}, },
{ {
@ -398,9 +399,11 @@ const nestedDelta = {
'type': 'image', 'type': 'image',
'data': { 'data': {
'url': 'https://richtexteditor.com/images/editor-image.png', 'url': 'https://richtexteditor.com/images/editor-image.png',
'content': null,
'align': 'center', 'align': 'center',
'height': null, 'height': null,
'width': null 'width': null,
'imageSourceType': 'network'
} }
} }
], ],

View file

@ -1,6 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'dart:developer';
void main() { void main() {
group('html_document_test.dart tests', () { group('html_document_test.dart tests', () {
@ -301,7 +300,7 @@ const rawHTML =
'''<h1>AppFlowyEditor</h1><h2>👋 <strong>Welcome to</strong> <span style="font-weight: bold; font-style: italic">AppFlowy Editor</span></h2><p>AppFlowy Editor is a <strong>highly customizable</strong> <i>rich-text editor</i></p><p> <u>Here</u> is an example <del>your</del> you can give a try</p><p> <span style="font-weight: bold; font-style: italic">Span element</span></p><p> <u>Span element two</u></p><p> <span style="font-weight: bold; text-decoration: line-through">Span element three</span></p><p> <a href="https://appflowy.io">This is an anchor tag!</a></p><h3>Features!</h3><ul><li>[x] Customizable</li><li>[x] Test-covered</li><li>[ ] more to come!</li><li>First item</li><li>Second item</li><li>List element</li></ul><blockquote>This is a quote!</blockquote><p><code> Code block</code></p><p> <i>Italic one</i></p><p> <i>Italic two</i></p><p> <strong>Bold tag</strong></p><p>You can also use <span style="font-weight: bold; font-style: italic">AppFlowy Editor</span> as a component to build your own app. </p><h3>Awesome features</h3><p>If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!</p><p></p><p></p>'''; '''<h1>AppFlowyEditor</h1><h2>👋 <strong>Welcome to</strong> <span style="font-weight: bold; font-style: italic">AppFlowy Editor</span></h2><p>AppFlowy Editor is a <strong>highly customizable</strong> <i>rich-text editor</i></p><p> <u>Here</u> is an example <del>your</del> you can give a try</p><p> <span style="font-weight: bold; font-style: italic">Span element</span></p><p> <u>Span element two</u></p><p> <span style="font-weight: bold; text-decoration: line-through">Span element three</span></p><p> <a href="https://appflowy.io">This is an anchor tag!</a></p><h3>Features!</h3><ul><li>[x] Customizable</li><li>[x] Test-covered</li><li>[ ] more to come!</li><li>First item</li><li>Second item</li><li>List element</li></ul><blockquote>This is a quote!</blockquote><p><code> Code block</code></p><p> <i>Italic one</i></p><p> <i>Italic two</i></p><p> <strong>Bold tag</strong></p><p>You can also use <span style="font-weight: bold; font-style: italic">AppFlowy Editor</span> as a component to build your own app. </p><h3>Awesome features</h3><p>If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!</p><p></p><p></p>''';
const nestedhtml = const nestedhtml =
'''<h1>Welcome to the playground</h1><blockquote>In case you were wondering what the black box at the bottom is – it\'s the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting. The playground is a demo environment built with <code>@lexical/react</code>. Try typing in <strong>some text</strong> with <i>different</i> formats.</blockquote><p>\t</p><img src="https://richtexteditor.com/images/editor-image.png"><p>Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!</p><p></p><p>If you\'d like to find out more about Lexical, you can:</p><ul><li>Visit the <a href="https://lexical.dev/">Lexical website</a> for documentation and more information.</li><li>\t<span><img src="https://richtexteditor.com/images/editor-image.png"></span></li><li>Check out the code on our <a href="https://github.com/facebook/lexical">GitHub repository</a>.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li><li>Join our <a href="https://discord.com/invite/KmG4wQnnD9">Discord Server</a> and chat with the team.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li></ul><p>Lastly, we\'re constantly adding cool new features to this playground. So make sure you check back here when you next get a chance 🙂.</p><p></p>'''; '''<h1>Welcome to the playground</h1><blockquote>In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting. The playground is a demo environment built with <code>@lexical/react</code>. Try typing in <strong>some text</strong> with <i>different</i> formats.</blockquote><p>\t</p><img src="https://richtexteditor.com/images/editor-image.png"><p>Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!</p><p></p><p>If you'd like to find out more about Lexical, you can:</p><ul><li>Visit the <a href="https://lexical.dev/">Lexical website</a> for documentation and more information.</li><li>\t<span><img src="https://richtexteditor.com/images/editor-image.png"></span></li><li>Check out the code on our <a href="https://github.com/facebook/lexical">GitHub repository</a>.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li><li>Join our <a href="https://discord.com/invite/KmG4wQnnD9">Discord Server</a> and chat with the team.</li><li>Playground code can be found <a href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground">here</a>.</li></ul><p>Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance 🙂.</p><p></p>''';
const nestedDelta = { const nestedDelta = {
'document': { 'document': {
'type': 'page', 'type': 'page',
@ -353,9 +352,11 @@ const nestedDelta = {
'type': 'image', 'type': 'image',
'data': { 'data': {
'url': 'https://richtexteditor.com/images/editor-image.png', 'url': 'https://richtexteditor.com/images/editor-image.png',
'content': null,
'align': 'center', 'align': 'center',
'height': null, 'height': null,
'width': null 'width': null,
'imageSourceType': 'network'
} }
}, },
{ {
@ -404,9 +405,11 @@ const nestedDelta = {
'type': 'image', 'type': 'image',
'data': { 'data': {
'url': 'https://richtexteditor.com/images/editor-image.png', 'url': 'https://richtexteditor.com/images/editor-image.png',
'content': null,
'align': 'center', 'align': 'center',
'height': null, 'height': null,
'width': null 'width': null,
'imageSourceType': 'network'
} }
} }
], ],

View file

@ -20,7 +20,9 @@ void main() async {
mockNetworkImagesFor(() async { mockNetworkImagesFor(() async {
final editor = tester.editor final editor = tester.editor
..addParagraph(initialText: text) ..addParagraph(initialText: text)
..addNode(imageNode(url: url)) ..addNode(
imageNode(url: url, imageSourceType: ImageSourceType.network),
)
..addParagraph(initialText: text); ..addParagraph(initialText: text);
await editor.startTesting(); await editor.startTesting();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -36,7 +38,9 @@ void main() async {
mockNetworkImagesFor(() async { mockNetworkImagesFor(() async {
final editor = tester.editor final editor = tester.editor
..addParagraph(initialText: text) ..addParagraph(initialText: text)
..addNode(imageNode(url: url)) ..addNode(
imageNode(url: url, imageSourceType: ImageSourceType.network),
)
..addParagraph(initialText: text); ..addParagraph(initialText: text);
await editor.startTesting(editable: false); await editor.startTesting(editable: false);
@ -65,7 +69,9 @@ void main() async {
mockNetworkImagesFor(() async { mockNetworkImagesFor(() async {
final editor = tester.editor final editor = tester.editor
..addParagraph(initialText: text) ..addParagraph(initialText: text)
..addNode(imageNode(url: url)) ..addNode(
imageNode(url: url, imageSourceType: ImageSourceType.network),
)
..addParagraph(initialText: text); ..addParagraph(initialText: text);
await editor.startTesting(); await editor.startTesting();
@ -94,9 +100,30 @@ void main() async {
mockNetworkImagesFor(() async { mockNetworkImagesFor(() async {
final editor = tester.editor final editor = tester.editor
..addParagraph(initialText: text) ..addParagraph(initialText: text)
..addNode(imageNode(url: url, align: 'left', width: 100)) ..addNode(
..addNode(imageNode(url: url, align: 'center', width: 100)) imageNode(
..addNode(imageNode(url: url, align: 'right', width: 100)) url: url,
align: 'left',
width: 100,
imageSourceType: ImageSourceType.network,
),
)
..addNode(
imageNode(
url: url,
align: 'center',
width: 100,
imageSourceType: ImageSourceType.network,
),
)
..addNode(
imageNode(
url: url,
align: 'right',
width: 100,
imageSourceType: ImageSourceType.network,
),
)
..addParagraph(initialText: text); ..addParagraph(initialText: text);
await editor.startTesting(); await editor.startTesting();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -148,7 +175,9 @@ void main() async {
mockNetworkImagesFor(() async { mockNetworkImagesFor(() async {
final editor = tester.editor final editor = tester.editor
..addParagraph(initialText: text) ..addParagraph(initialText: text)
..addNode(imageNode(url: url)) ..addNode(
imageNode(url: url, imageSourceType: ImageSourceType.network),
)
..addParagraph(initialText: text); ..addParagraph(initialText: text);
await editor.startTesting(); await editor.startTesting();
@ -156,7 +185,7 @@ void main() async {
final imageFinder = find.byType(Image); final imageFinder = find.byType(Image);
expect(imageFinder, findsOneWidget); expect(imageFinder, findsOneWidget);
final node = editor.document.nodeAtPath([1]); // final node = editor.document.nodeAtPath([1]);
// expect(editor.runAction(3, imageNode!), true); // copy // expect(editor.runAction(3, imageNode!), true); // copy
// await tester.pump(); // await tester.pump();
@ -167,8 +196,12 @@ void main() async {
mockNetworkImagesFor(() async { mockNetworkImagesFor(() async {
final editor = tester.editor final editor = tester.editor
..addParagraph(initialText: text) ..addParagraph(initialText: text)
..addNode(imageNode(url: url)) ..addNode(
..addNode(imageNode(url: url)) imageNode(url: url, imageSourceType: ImageSourceType.network),
)
..addNode(
imageNode(url: url, imageSourceType: ImageSourceType.network),
)
..addParagraph(initialText: text); ..addParagraph(initialText: text);
await editor.startTesting(); await editor.startTesting();