feat: support uploading images from local files (#232)
This commit is contained in:
parent
fdf45f9793
commit
ed75bf61df
14 changed files with 676 additions and 340 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
lib/src/editor/util/file_picker/file_picker_impl.dart
Normal file
56
lib/src/editor/util/file_picker/file_picker_impl.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
lib/src/editor/util/file_picker/file_picker_service.dart
Normal file
40
lib/src/editor/util/file_picker/file_picker_service.dart
Normal 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.');
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue