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: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 {
|
||||
ImageBlockKeys._();
|
||||
|
|
@ -30,10 +46,23 @@ class ImageBlockKeys {
|
|||
///
|
||||
/// The value is a double.
|
||||
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({
|
||||
required String url,
|
||||
required ImageSourceType imageSourceType,
|
||||
String? content,
|
||||
String? url,
|
||||
String align = 'center',
|
||||
double? height,
|
||||
double? width,
|
||||
|
|
@ -42,9 +71,11 @@ Node imageNode({
|
|||
type: ImageBlockKeys.type,
|
||||
attributes: {
|
||||
ImageBlockKeys.url: url,
|
||||
ImageBlockKeys.content: content,
|
||||
ImageBlockKeys.align: align,
|
||||
ImageBlockKeys.height: height,
|
||||
ImageBlockKeys.width: width,
|
||||
ImageBlockKeys.imageSourceType: imageSourceType.name,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -73,10 +104,7 @@ class ImageBlockComponentBuilder extends BlockComponentBuilder {
|
|||
}
|
||||
|
||||
@override
|
||||
bool validate(Node node) =>
|
||||
node.delta == null &&
|
||||
node.children.isEmpty &&
|
||||
node.attributes[ImageBlockKeys.url] is String;
|
||||
bool validate(Node node) => node.delta == null && node.children.isEmpty;
|
||||
}
|
||||
|
||||
class ImageBlockComponentWidget extends BlockComponentStatefulWidget {
|
||||
|
|
@ -93,7 +121,10 @@ class ImageBlockComponentWidget extends BlockComponentStatefulWidget {
|
|||
_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);
|
||||
|
||||
@override
|
||||
|
|
@ -101,15 +132,23 @@ class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget> {
|
|||
final node = widget.node;
|
||||
final attributes = node.attributes;
|
||||
final src = attributes[ImageBlockKeys.url];
|
||||
final align = attributes[ImageBlockKeys.align] ?? 'center';
|
||||
final width = attributes[ImageBlockKeys.width]?.toDouble();
|
||||
final content = attributes[ImageBlockKeys.content];
|
||||
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(
|
||||
node: node,
|
||||
Widget child = ResizableImage(
|
||||
src: src,
|
||||
content: content,
|
||||
width: width,
|
||||
editable: editorState.editable,
|
||||
alignment: _textToAlignment(align),
|
||||
alignment: alignment,
|
||||
type: imageSourceType,
|
||||
onResize: (width) {
|
||||
final transaction = editorState.transaction
|
||||
..updateNode(node, {
|
||||
|
|
@ -130,12 +169,51 @@ class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget> {
|
|||
return child;
|
||||
}
|
||||
|
||||
Alignment _textToAlignment(String text) {
|
||||
if (text == 'left') {
|
||||
return Alignment.centerLeft;
|
||||
} else if (text == 'right') {
|
||||
return Alignment.centerRight;
|
||||
@override
|
||||
Position start() => Position(path: widget.node.path, offset: 0);
|
||||
|
||||
@override
|
||||
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/src/editor/block_component/image_block_component/base64_image.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(
|
||||
OverlayState container,
|
||||
|
|
@ -12,8 +22,11 @@ void showImageMenu(
|
|||
|
||||
late final OverlayEntry imageMenuEntry;
|
||||
|
||||
void insertImage(String text) {
|
||||
editorState.insertImageNode(text);
|
||||
void insertImage(
|
||||
String src, {
|
||||
ImageSourceType imageSourceType = ImageSourceType.network,
|
||||
}) {
|
||||
editorState.insertImageNode(src, imageSourceType);
|
||||
menuService.dismiss();
|
||||
imageMenuEntry.remove();
|
||||
keepEditorFocusNotifier.value -= 1;
|
||||
|
|
@ -50,15 +63,20 @@ class UploadImageMenu extends StatefulWidget {
|
|||
final Color headerColor;
|
||||
final double width;
|
||||
final void Function(String text) onSubmitted;
|
||||
final void Function(String text) onUpload;
|
||||
final void Function(String text, {ImageSourceType imageSourceType}) onUpload;
|
||||
|
||||
@override
|
||||
State<UploadImageMenu> createState() => _UploadImageMenuState();
|
||||
}
|
||||
|
||||
class _UploadImageMenuState extends State<UploadImageMenu> {
|
||||
static const allowedExtensions = ['jpg', 'png', 'jpeg'];
|
||||
|
||||
final _textEditingController = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
final _filePicker = FilePicker();
|
||||
|
||||
String? _localImagePath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -76,6 +94,7 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
|||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: 350,
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.backgroundColor,
|
||||
|
|
@ -88,15 +107,37 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
|||
],
|
||||
// borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const SizedBox(height: 16.0),
|
||||
_buildInput(),
|
||||
const SizedBox(height: 18.0),
|
||||
_buildUploadButton(context),
|
||||
],
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
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(
|
||||
width: 170,
|
||||
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(
|
||||
'Upload',
|
||||
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 {
|
||||
Future<void> insertImageNode(String src) async {
|
||||
Future<void> insertImageNode(
|
||||
String src,
|
||||
ImageSourceType imageSourceType,
|
||||
) async {
|
||||
final selection = this.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
|
|
@ -177,12 +323,23 @@ extension on EditorState {
|
|||
}
|
||||
final transaction = this.transaction;
|
||||
// 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
|
||||
..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);
|
||||
} else {
|
||||
transaction.insertNode(node.path.next, imageNode(url: src));
|
||||
transaction.insertNode(
|
||||
node.path.next,
|
||||
imageNode(url: src, imageSourceType: imageSourceType),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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(
|
||||
url: src,
|
||||
imageSourceType: ImageSourceType.network,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -188,9 +188,14 @@ class DocumentHTMLDecoder extends Converter<String, Document> {
|
|||
}
|
||||
|
||||
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(
|
||||
url: src,
|
||||
imageSourceType: ImageSourceType.network,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ dependencies:
|
|||
collection: ^1.17.0
|
||||
nanoid: ^1.0.0
|
||||
visibility_detector: ^0.4.0+2
|
||||
|
||||
file_picker: ^5.3.1
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
|
|
@ -14,7 +13,7 @@ void main() async {
|
|||
expect(result.toJson(), example);
|
||||
});
|
||||
test('nested parser document', () async {
|
||||
final result = DocumentHTMLDecoder().convert(nestedhtml);
|
||||
final result = DocumentHTMLDecoder().convert(nestedHTML);
|
||||
|
||||
expect(result.toJson(), nestedDelta);
|
||||
});
|
||||
|
|
@ -294,8 +293,8 @@ const example = {
|
|||
]
|
||||
}
|
||||
};
|
||||
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>''';
|
||||
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>''';
|
||||
const nestedDelta = {
|
||||
'document': {
|
||||
'type': 'page',
|
||||
|
|
@ -347,9 +346,11 @@ const nestedDelta = {
|
|||
'type': 'image',
|
||||
'data': {
|
||||
'url': 'https://richtexteditor.com/images/editor-image.png',
|
||||
'content': null,
|
||||
'align': 'center',
|
||||
'height': null,
|
||||
'width': null
|
||||
'width': null,
|
||||
'imageSourceType': 'network'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -398,9 +399,11 @@ const nestedDelta = {
|
|||
'type': 'image',
|
||||
'data': {
|
||||
'url': 'https://richtexteditor.com/images/editor-image.png',
|
||||
'content': null,
|
||||
'align': 'center',
|
||||
'height': null,
|
||||
'width': null
|
||||
'width': null,
|
||||
'imageSourceType': 'network'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ const delta = {
|
|||
}
|
||||
};
|
||||
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 = {
|
||||
'document': {
|
||||
'type': 'page',
|
||||
|
|
@ -349,7 +349,8 @@ const nestedDelta = {
|
|||
'url': 'https://richtexteditor.com/images/editor-image.png',
|
||||
'align': 'center',
|
||||
'height': null,
|
||||
'width': null
|
||||
'width': null,
|
||||
'imageSourceType': 'network'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -398,9 +399,11 @@ const nestedDelta = {
|
|||
'type': 'image',
|
||||
'data': {
|
||||
'url': 'https://richtexteditor.com/images/editor-image.png',
|
||||
'content': null,
|
||||
'align': 'center',
|
||||
'height': null,
|
||||
'width': null
|
||||
'width': null,
|
||||
'imageSourceType': 'network'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'dart:developer';
|
||||
|
||||
void main() {
|
||||
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>''';
|
||||
|
||||
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 = {
|
||||
'document': {
|
||||
'type': 'page',
|
||||
|
|
@ -353,9 +352,11 @@ const nestedDelta = {
|
|||
'type': 'image',
|
||||
'data': {
|
||||
'url': 'https://richtexteditor.com/images/editor-image.png',
|
||||
'content': null,
|
||||
'align': 'center',
|
||||
'height': null,
|
||||
'width': null
|
||||
'width': null,
|
||||
'imageSourceType': 'network'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -404,9 +405,11 @@ const nestedDelta = {
|
|||
'type': 'image',
|
||||
'data': {
|
||||
'url': 'https://richtexteditor.com/images/editor-image.png',
|
||||
'content': null,
|
||||
'align': 'center',
|
||||
'height': null,
|
||||
'width': null
|
||||
'width': null,
|
||||
'imageSourceType': 'network'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ void main() async {
|
|||
mockNetworkImagesFor(() async {
|
||||
final editor = tester.editor
|
||||
..addParagraph(initialText: text)
|
||||
..addNode(imageNode(url: url))
|
||||
..addNode(
|
||||
imageNode(url: url, imageSourceType: ImageSourceType.network),
|
||||
)
|
||||
..addParagraph(initialText: text);
|
||||
await editor.startTesting();
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -36,7 +38,9 @@ void main() async {
|
|||
mockNetworkImagesFor(() async {
|
||||
final editor = tester.editor
|
||||
..addParagraph(initialText: text)
|
||||
..addNode(imageNode(url: url))
|
||||
..addNode(
|
||||
imageNode(url: url, imageSourceType: ImageSourceType.network),
|
||||
)
|
||||
..addParagraph(initialText: text);
|
||||
|
||||
await editor.startTesting(editable: false);
|
||||
|
|
@ -65,7 +69,9 @@ void main() async {
|
|||
mockNetworkImagesFor(() async {
|
||||
final editor = tester.editor
|
||||
..addParagraph(initialText: text)
|
||||
..addNode(imageNode(url: url))
|
||||
..addNode(
|
||||
imageNode(url: url, imageSourceType: ImageSourceType.network),
|
||||
)
|
||||
..addParagraph(initialText: text);
|
||||
|
||||
await editor.startTesting();
|
||||
|
|
@ -94,9 +100,30 @@ void main() async {
|
|||
mockNetworkImagesFor(() async {
|
||||
final editor = tester.editor
|
||||
..addParagraph(initialText: text)
|
||||
..addNode(imageNode(url: url, align: 'left', width: 100))
|
||||
..addNode(imageNode(url: url, align: 'center', width: 100))
|
||||
..addNode(imageNode(url: url, align: 'right', width: 100))
|
||||
..addNode(
|
||||
imageNode(
|
||||
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);
|
||||
await editor.startTesting();
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -148,7 +175,9 @@ void main() async {
|
|||
mockNetworkImagesFor(() async {
|
||||
final editor = tester.editor
|
||||
..addParagraph(initialText: text)
|
||||
..addNode(imageNode(url: url))
|
||||
..addNode(
|
||||
imageNode(url: url, imageSourceType: ImageSourceType.network),
|
||||
)
|
||||
..addParagraph(initialText: text);
|
||||
await editor.startTesting();
|
||||
|
||||
|
|
@ -156,7 +185,7 @@ void main() async {
|
|||
final imageFinder = find.byType(Image);
|
||||
expect(imageFinder, findsOneWidget);
|
||||
|
||||
final node = editor.document.nodeAtPath([1]);
|
||||
// final node = editor.document.nodeAtPath([1]);
|
||||
|
||||
// expect(editor.runAction(3, imageNode!), true); // copy
|
||||
// await tester.pump();
|
||||
|
|
@ -167,8 +196,12 @@ void main() async {
|
|||
mockNetworkImagesFor(() async {
|
||||
final editor = tester.editor
|
||||
..addParagraph(initialText: text)
|
||||
..addNode(imageNode(url: url))
|
||||
..addNode(imageNode(url: url))
|
||||
..addNode(
|
||||
imageNode(url: url, imageSourceType: ImageSourceType.network),
|
||||
)
|
||||
..addNode(
|
||||
imageNode(url: url, imageSourceType: ImageSourceType.network),
|
||||
)
|
||||
..addParagraph(initialText: text);
|
||||
await editor.startTesting();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue