diff --git a/packages/notus/lib/notus.dart b/packages/notus/lib/notus.dart index d61bcee0d..2c0179e79 100644 --- a/packages/notus/lib/notus.dart +++ b/packages/notus/lib/notus.dart @@ -15,3 +15,4 @@ export 'src/heuristics.dart'; export 'src/heuristics/delete_rules.dart'; export 'src/heuristics/format_rules.dart'; export 'src/heuristics/insert_rules.dart'; +export 'src/history.dart'; diff --git a/packages/notus/lib/src/document.dart b/packages/notus/lib/src/document.dart index 442486897..ad26a56cf 100644 --- a/packages/notus/lib/src/document.dart +++ b/packages/notus/lib/src/document.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:quill_delta/quill_delta.dart'; +import 'package:notus/notus.dart'; import 'document/attributes.dart'; import 'document/block.dart'; @@ -40,12 +41,14 @@ class NotusDocument { /// Creates new empty Notus document. NotusDocument() : _heuristics = NotusHeuristics.fallback, + _history = NotusHistory(), _delta = Delta()..insert('\n') { _loadDocument(_delta); } NotusDocument.fromJson(List data) : _heuristics = NotusHeuristics.fallback, + _history = NotusHistory(), _delta = Delta.fromJson(data) { _loadDocument(_delta); } @@ -53,12 +56,22 @@ class NotusDocument { NotusDocument.fromDelta(Delta delta) : assert(delta != null), _heuristics = NotusHeuristics.fallback, + _history = NotusHistory(), _delta = delta { _loadDocument(_delta); } final NotusHeuristics _heuristics; + NotusHistory _history; + + set history(NotusHistory value) { + _history?.clear(); + _history = value; + } + + NotusHistory get history => _history; + /// The root node of this document tree. RootNode get root => _root; final RootNode _root = RootNode(); @@ -92,6 +105,7 @@ class NotusDocument { /// Closes [changes] stream. void close() { _controller.close(); + _history.clear(); } /// Inserts [text] in this document at specified [index]. @@ -216,7 +230,7 @@ class NotusDocument { /// of this document. /// /// In case the [change] is invalid, behavior of this method is unspecified. - void compose(Delta change, ChangeSource source) { + void compose(Delta change, ChangeSource source, {bool history}) { _checkMutable(); change.trim(); assert(change.isNotEmpty); @@ -241,7 +255,9 @@ class NotusDocument { throw StateError('Compose produced inconsistent results. ' 'This is likely due to a bug in the library. Tried to compose change $change from $source.'); } - _controller.add(NotusChange(before, change, source)); + final notusChange = NotusChange(before, change, source); + _controller.add(notusChange); + if (history != true) _history?.handleDocChange(notusChange); } // @@ -293,4 +309,12 @@ class NotusDocument { _root.remove(node); } } + + void undo() { + _history?.undo(this); + } + + void redo() { + _history?.redo(this); + } } diff --git a/packages/notus/lib/src/document/attributes.dart b/packages/notus/lib/src/document/attributes.dart index f2f222075..103466470 100644 --- a/packages/notus/lib/src/document/attributes.dart +++ b/packages/notus/lib/src/document/attributes.dart @@ -56,17 +56,24 @@ abstract class NotusAttributeBuilder implements NotusAttributeKey { /// document.format(0, 5, NotusAttribute.bold); /// // Similarly for italic /// document.format(0, 5, NotusAttribute.italic); +/// // Similarly for underline +/// document.format(0, 5, NotusAttribute.underline) /// // Format first line as a heading (h1) /// // Note that there is no need to specify character range of the whole /// // line. Simply set index position to anywhere within the line and /// // length to 0. /// document.format(0, 0, NotusAttribute.h1); +/// // Note that there is no need to specify character range of the whole +/// // line. Simply set index position to anywhere within the line and +/// // length to 0. +/// document.format(0, 0, NotusAttribute.left); /// } /// /// List of supported attributes: /// /// * [NotusAttribute.bold] /// * [NotusAttribute.italic] +/// * [NotusAttribute.underline] /// * [NotusAttribute.link] /// * [NotusAttribute.heading] /// * [NotusAttribute.block] @@ -74,6 +81,7 @@ class NotusAttribute implements NotusAttributeBuilder { static final Map _registry = { NotusAttribute.bold.key: NotusAttribute.bold, NotusAttribute.italic.key: NotusAttribute.italic, + NotusAttribute.underline.key: NotusAttribute.underline, NotusAttribute.link.key: NotusAttribute.link, NotusAttribute.heading.key: NotusAttribute.heading, NotusAttribute.block.key: NotusAttribute.block, @@ -88,6 +96,9 @@ class NotusAttribute implements NotusAttributeBuilder { /// Italic style attribute. static const italic = _ItalicAttribute(); + /// Underline style attribure. + static const underline = _UnderlineAttribute(); + /// Link style attribute. // ignore: const_eval_throws_exception static const link = LinkAttributeBuilder._(); @@ -120,6 +131,18 @@ class NotusAttribute implements NotusAttributeBuilder { /// Alias for [NotusAttribute.block.quote]. static NotusAttribute get bq => block.quote; + /// Alias for [NotusAttribute.block.alignLeft]. + static NotusAttribute get alignLeft => block.alignLeft; + + /// Alias for [NotusAttribute.block.alignRight]. + static NotusAttribute get alignRight => block.alignRight; + + /// Alias for [NotusAttribute.block.alignCenter]. + static NotusAttribute get alignCenter => block.alignCenter; + + /// Alias for [NotusAttribute.block.alignJustify]. + static NotusAttribute get alignJustify => block.alignJustify; + /// Alias for [NotusAttribute.block.code]. static NotusAttribute get code => block.code; @@ -332,6 +355,11 @@ class _ItalicAttribute extends NotusAttribute { const _ItalicAttribute() : super._('i', NotusAttributeScope.inline, true); } +/// Applies underline style to a text segment. +class _UnderlineAttribute extends NotusAttribute { + const _UnderlineAttribute() : super._('u', NotusAttributeScope.inline, true); +} + /// Builder for link attribute values. /// /// There is no need to use this class directly, consider using @@ -387,6 +415,22 @@ class BlockAttributeBuilder extends NotusAttributeBuilder { /// Formats a block of lines as a quote. NotusAttribute get quote => NotusAttribute._(key, scope, 'quote'); + + /// Formats a block of lines into align left. + NotusAttribute get alignLeft => + NotusAttribute._(key, scope, 'alignLeft'); + + /// Formats a block of lines into align right. + NotusAttribute get alignRight => + NotusAttribute._(key, scope, 'alignRight'); + + /// Formats a block of lines into align center. + NotusAttribute get alignCenter => + NotusAttribute._(key, scope, 'alignCenter'); + + /// Formats a block of lines into align justify. + NotusAttribute get alignJustify => + NotusAttribute._(key, scope, 'alignJustify'); } class EmbedAttributeBuilder @@ -399,6 +443,9 @@ class EmbedAttributeBuilder NotusAttribute> image(String source) => EmbedAttribute.image(source); + + NotusAttribute> video(String source) => + EmbedAttribute.video(source); @override NotusAttribute> get unset => EmbedAttribute._(null); @@ -409,13 +456,14 @@ class EmbedAttributeBuilder } /// Type of embedded content. -enum EmbedType { horizontalRule, image } +enum EmbedType { horizontalRule, image, video } class EmbedAttribute extends NotusAttribute> { static const _kValueEquality = MapEquality(); static const _kEmbed = 'embed'; static const _kHorizontalRuleEmbed = 'hr'; static const _kImageEmbed = 'image'; + static const _KVideoEmbed = 'video'; EmbedAttribute._(Map value) : super._(_kEmbed, NotusAttributeScope.inline, value); @@ -426,10 +474,14 @@ class EmbedAttribute extends NotusAttribute> { EmbedAttribute.image(String source) : this._({'type': _kImageEmbed, 'source': source}); + EmbedAttribute.video(String source) + : this._({'type': _KVideoEmbed, 'source': source}); + /// Type of this embed. EmbedType get type { if (value['type'] == _kHorizontalRuleEmbed) return EmbedType.horizontalRule; if (value['type'] == _kImageEmbed) return EmbedType.image; + if (value['type'] == _KVideoEmbed) return EmbedType.video; assert(false, 'Unknown embed attribute value $value.'); return null; } diff --git a/packages/notus/lib/src/document/block.dart b/packages/notus/lib/src/document/block.dart index 3dc561456..a2729482a 100644 --- a/packages/notus/lib/src/document/block.dart +++ b/packages/notus/lib/src/document/block.dart @@ -10,7 +10,7 @@ import 'node.dart'; /// A block represents a group of adjacent [LineNode]s with the same block /// style. /// -/// Block examples: lists, quotes, code snippets. +/// Block examples: lists, quotes, code snippets, alignment. class BlockNode extends ContainerNode with StyledNodeMixin implements StyledNode { diff --git a/packages/notus/lib/src/history.dart b/packages/notus/lib/src/history.dart new file mode 100644 index 000000000..ef6abb12c --- /dev/null +++ b/packages/notus/lib/src/history.dart @@ -0,0 +1,119 @@ +import 'package:notus/notus.dart'; +import 'package:notus/src/document.dart'; +import 'package:quill_delta/quill_delta.dart'; + +/// +/// record users operation or api change(Collaborative editing) +/// used for redo or undo function +/// +class NotusHistory { + final NotusHistoryStack stack = NotusHistoryStack.empty(); + + /// used for disable redo or undo function + bool ignoreChange; + + int lastRecorded; + + ///Collaborative editing's conditions should be true + final bool userOnly; + + ///max operation count for undo + final int maxStack; + + ///record delay + final int interval; + + NotusHistory( + {this.ignoreChange = false, + this.interval = 400, + this.maxStack = 100, + this.userOnly = false, + this.lastRecorded = 0}); + + void handleDocChange(NotusChange event) { + if (ignoreChange) return; + if (!userOnly || event.source == ChangeSource.local) { + record(event.change, event.before); + } else { + transform(event.change); + } + } + + void clear() { + stack.clear(); + } + + void record(Delta change, Delta before) { + if (change.isEmpty) return; + stack.redo.clear(); + Delta undoDelta = change.invert(before); + final timeStamp = DateTime.now().millisecondsSinceEpoch; + + if (lastRecorded + interval > timeStamp && stack.undo.isNotEmpty) { + final lastDelta = stack.undo.removeLast(); + undoDelta = undoDelta.compose(lastDelta); + } else { + lastRecorded = timeStamp; + } + + if (undoDelta.isEmpty) return; + stack.undo.add(undoDelta); + + if (stack.undo.length > maxStack) { + stack.undo.removeAt(0); + } + } + + /// + ///It will override pre local undo delta,replaced by remote change + /// + void transform(Delta delta) { + transformStack(this.stack.undo, delta); + transformStack(this.stack.redo, delta); + } + + void transformStack(List stack, Delta delta) { + for (int i = stack.length - 1; i >= 0; i -= 1) { + final oldDelta = stack[i]; + stack[i] = delta.transform(oldDelta, true); + delta = oldDelta.transform(delta, false); + if (stack[i].length == 0) { + stack.removeAt(i); + } + } + } + + void _change(NotusDocument doc, List source, List dest) { + if (source.length == 0) return; + Delta delta = source.removeLast(); + Delta base = doc.toDelta(); + Delta inverseDelta = delta.invert(base); + dest.add(inverseDelta); + this.lastRecorded = 0; + this.ignoreChange = true; + doc.compose(delta, ChangeSource.local, history: true); + this.ignoreChange = false; + } + + void undo(NotusDocument doc) { + _change(doc, stack.undo, stack.redo); + } + + void redo(NotusDocument doc) { + _change(doc, stack.redo, stack.undo); + } +} + +class NotusHistoryStack { + final List undo; + final List redo; + + NotusHistoryStack.empty() + : undo = [], + redo = []; + + void clear() { + undo.clear(); + redo.clear(); + } +} diff --git a/packages/notus/pubspec.yaml b/packages/notus/pubspec.yaml index 98777765d..91b6747a0 100644 --- a/packages/notus/pubspec.yaml +++ b/packages/notus/pubspec.yaml @@ -5,7 +5,7 @@ author: Anatoly Pulyaevskiy homepage: https://github.com/memspace/zefyr environment: - sdk: '>=2.2.0 <3.0.0' + sdk: ">=2.2.0 <3.0.0" dependencies: collection: ^1.14.6 diff --git a/packages/zefyr/example/android/gradle.properties b/packages/zefyr/example/android/gradle.properties index 8bd86f680..7be3d8b46 100644 --- a/packages/zefyr/example/android/gradle.properties +++ b/packages/zefyr/example/android/gradle.properties @@ -1 +1,2 @@ org.gradle.jvmargs=-Xmx1536M +android.enableR8=true diff --git a/packages/zefyr/example/lib/generated_plugin_registrant.dart b/packages/zefyr/example/lib/generated_plugin_registrant.dart new file mode 100644 index 000000000..26bf2561c --- /dev/null +++ b/packages/zefyr/example/lib/generated_plugin_registrant.dart @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +// ignore_for_file: lines_longer_than_80_chars + +import 'package:url_launcher_web/url_launcher_web.dart'; + +// ignore: public_member_api_docs +void registerPlugins(Registrar registrar) { + UrlLauncherPlugin.registerWith(registrar); + registrar.messenger; //registerMessageHandler(); +} diff --git a/packages/zefyr/example/lib/src/form.dart b/packages/zefyr/example/lib/src/form.dart index 1b7024773..39fa95333 100644 --- a/packages/zefyr/example/lib/src/form.dart +++ b/packages/zefyr/example/lib/src/form.dart @@ -32,7 +32,7 @@ class _FormEmbeddedScreenState extends State { ); final result = Scaffold( - resizeToAvoidBottomPadding: true, + resizeToAvoidBottomInset: true, appBar: AppBar( title: ZefyrLogo(), actions: [ diff --git a/packages/zefyr/example/lib/src/full_page.dart b/packages/zefyr/example/lib/src/full_page.dart index 599f50f7e..948c310ed 100644 --- a/packages/zefyr/example/lib/src/full_page.dart +++ b/packages/zefyr/example/lib/src/full_page.dart @@ -10,6 +10,7 @@ import 'package:quill_delta/quill_delta.dart'; import 'package:zefyr/zefyr.dart'; import 'images.dart'; +import 'videos.dart'; class ZefyrLogo extends StatelessWidget { @override @@ -31,7 +32,7 @@ class FullPageEditorScreen extends StatefulWidget { } final doc = - r'[{"insert":"Zefyr"},{"insert":"\n","attributes":{"heading":1}},{"insert":"Soft and gentle rich text editing for Flutter applications.","attributes":{"i":true}},{"insert":"\n"},{"insert":"​","attributes":{"embed":{"type":"image","source":"asset://images/breeze.jpg"}}},{"insert":"\n"},{"insert":"Photo by Hiroyuki Takeda.","attributes":{"i":true}},{"insert":"\nZefyr is currently in "},{"insert":"early preview","attributes":{"b":true}},{"insert":". If you have a feature request or found a bug, please file it at the "},{"insert":"issue tracker","attributes":{"a":"https://github.com/memspace/zefyr/issues"}},{"insert":' + r'[{"insert":"Zefyr"},{"insert":"\n","attributes":{"heading":1}},{"insert":"Soft and gentle rich text editing for Flutter applications.","attributes":{"i":true}},{"insert":"\n"},{"insert":"​","attributes":{"embed":{"type":"image","source":"asset://images/breeze.jpg"}}},{"insert":"\n"},{"insert":"Photo by Hiroyuki Takeda.","attributes":{"i":true}},{"insert":"\n"},{"insert":"​","attributes":{"embed":{"type":"video","source":"asset://video/nature_videos_UCDQ_Stczw0.mp4"}}},{"insert":"\n"},{"insert":"\nZefyr is currently in "},{"insert":"early preview","attributes":{"b":true}},{"insert":". If you have a feature request or found a bug, please file it at the "},{"insert":"issue tracker","attributes":{"a":"https://github.com/memspace/zefyr/issues"}},{"insert":' r'".\nDocumentation"},{"insert":"\n","attributes":{"heading":3}},{"insert":"Quick Start","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/quick_start.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Data Format and Document Model","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/data_and_document.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Style Attributes","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/attr' r'ibutes.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Heuristic Rules","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/heuristics.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"FAQ","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/faq.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Clean and modern look"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Zefyr’s rich text editor is built with simplicity and fle' r'xibility in mind. It provides clean interface for distraction-free editing. Think Medium.com-like experience.\nMarkdown inspired semantics"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Ever needed to have a heading line inside of a quote block, like this:\nI’m a Markdown heading"},{"insert":"\n","attributes":{"block":"quote","heading":3}},{"insert":"And I’m a regular paragraph"},{"insert":"\n","attributes":{"block":"quote"}},{"insert":"Code blocks"},{"insert":"\n","attributes":{"headin' @@ -71,7 +72,7 @@ class _FullPageEditorScreenState extends State { ? IconButton(onPressed: _stopEditing, icon: Icon(Icons.save)) : IconButton(onPressed: _startEditing, icon: Icon(Icons.edit)); final result = Scaffold( - resizeToAvoidBottomPadding: true, + resizeToAvoidBottomInset: true, appBar: AppBar( title: ZefyrLogo(), actions: [ @@ -88,6 +89,7 @@ class _FullPageEditorScreenState extends State { focusNode: _focusNode, mode: _editing ? ZefyrMode.edit : ZefyrMode.select, imageDelegate: CustomImageDelegate(), + videoDelegate: CustomVideoDelegate(), keyboardAppearance: _darkTheme ? Brightness.dark : Brightness.light, ), ), diff --git a/packages/zefyr/example/lib/src/videoPlayer.dart b/packages/zefyr/example/lib/src/videoPlayer.dart new file mode 100644 index 000000000..103113734 --- /dev/null +++ b/packages/zefyr/example/lib/src/videoPlayer.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +class VideoApp extends StatefulWidget { + final String video; + VideoApp({this.video}); + @override + _VideoAppState createState() => _VideoAppState(); +} + +class _VideoAppState extends State { + VideoPlayerController _controller; + + @override + void initState() { + super.initState(); + + _controller = widget.video == null + ? VideoPlayerController.network( + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4') + : VideoPlayerController.asset(widget.video) + ..initialize().then((_) { + // Ensure the first frame is shown after the video is initialized, even before the play button has been pressed. + setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + height: 300, + child: InkWell( + onTap: () { + setState(() { + _controller.value.isPlaying + ? _controller.pause() + : _controller.play(); + }); + }, + child: Stack(alignment: Alignment.center, children: [ + Center( + child: _controller.value.initialized + ? AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ) + : CircularProgressIndicator()), + _controller.value.isPlaying || !_controller.value.initialized + ? SizedBox.shrink() + : Icon( + Icons.play_arrow, + size: 60, + color: Colors.white, + ) + ]), + ), + ); + } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } +} diff --git a/packages/zefyr/example/lib/src/videos.dart b/packages/zefyr/example/lib/src/videos.dart new file mode 100644 index 000000000..a15559fc8 --- /dev/null +++ b/packages/zefyr/example/lib/src/videos.dart @@ -0,0 +1,48 @@ +// Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:zefyr/zefyr.dart'; + +import 'videoPlayer.dart'; + +/// Custom image delegate used by this example to load image from application +/// assets. +class CustomVideoDelegate implements ZefyrVideoDelegate { + @override + ImageSource get cameraSource => ImageSource.camera; + + @override + ImageSource get gallerySource => ImageSource.gallery; + + /*@override + DataSourceType get asset => DataSourceType.asset; + + @override + DataSourceType get network => DataSourceType.network; + + @override + DataSourceType get file => DataSourceType.file;*/ + + @override + Future pickVideo(ImageSource source) async { + final picker = ImagePicker(); + final file = await picker.getVideo(source: ImageSource.gallery); + if (file == null) return null; + return file.path.toString(); + } + + @override + Widget buildVideo(BuildContext context, String key) { + // We use custom "asset" scheme to distinguish asset images from other files. + if (key.startsWith('asset://')) { + final asset = key.replaceFirst('asset://', ''); + return VideoApp(video: asset); + } else { + return VideoApp(); + } + } +} diff --git a/packages/zefyr/example/lib/src/view.dart b/packages/zefyr/example/lib/src/view.dart index 9a0bfcacc..56c9f8a76 100644 --- a/packages/zefyr/example/lib/src/view.dart +++ b/packages/zefyr/example/lib/src/view.dart @@ -4,6 +4,7 @@ import 'dart:convert'; +import 'package:example/src/videos.dart'; import 'package:flutter/material.dart'; import 'package:quill_delta/quill_delta.dart'; import 'package:zefyr/zefyr.dart'; @@ -17,7 +18,7 @@ class ViewScreen extends StatefulWidget { } final doc = - r'[{"insert":"Zefyr"},{"insert":"\n","attributes":{"heading":1}},{"insert":"Soft and gentle rich text editing for Flutter applications.","attributes":{"i":true}},{"insert":"\n"},{"insert":"​","attributes":{"embed":{"type":"image","source":"asset://images/breeze.jpg"}}},{"insert":"\n"},{"insert":"Photo by Hiroyuki Takeda.","attributes":{"i":true}},{"insert":"\nZefyr is currently in "},{"insert":"early preview","attributes":{"b":true}},{"insert":". If you have a feature request or found a bug, please file it at the "},{"insert":"issue tracker","attributes":{"a":"https://github.com/memspace/zefyr/issues"}},{"insert":' + r'[{"insert":"Zefyr"},{"insert":"\n","attributes":{"heading":1}},{"insert":"Soft and gentle rich text editing for Flutter applications.","attributes":{"i":true}},{"insert":"\n"},{"insert":"​","attributes":{"embed":{"type":"image","source":"asset://images/breeze.jpg"}}},{"insert":"\n"},{"insert":"Photo by Hiroyuki Takeda.","attributes":{"i":true}},{"insert":"\n"},{"insert":"​","attributes":{"embed":{"type":"video","source":"asset://video/nature_videos_UCDQ_Stczw0.mp4"}}},{"insert":"\n"},{"insert":"\nZefyr is currently in "},{"insert":"early preview","attributes":{"b":true}},{"insert":". If you have a feature request or found a bug, please file it at the "},{"insert":"issue tracker","attributes":{"a":"https://github.com/memspace/zefyr/issues"}},{"insert":' r'".\nDocumentation"},{"insert":"\n","attributes":{"heading":3}},{"insert":"Quick Start","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/quick_start.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Data Format and Document Model","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/data_and_document.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Style Attributes","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/attr' r'ibutes.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Heuristic Rules","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/heuristics.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"FAQ","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/faq.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Clean and modern look"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Zefyr’s rich text editor is built with simplicity and fle' r'xibility in mind. It provides clean interface for distraction-free editing. Think Medium.com-like experience.\nMarkdown inspired semantics"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Ever needed to have a heading line inside of a quote block, like this:\nI’m a Markdown heading"},{"insert":"\n","attributes":{"block":"quote","heading":3}},{"insert":"And I’m a regular paragraph"},{"insert":"\n","attributes":{"block":"quote"}},{"insert":"Code blocks"},{"insert":"\n","attributes":{"headin' @@ -33,7 +34,7 @@ class _ViewScreen extends State { @override Widget build(BuildContext context) { return Scaffold( - resizeToAvoidBottomPadding: true, + resizeToAvoidBottomInset: true, appBar: AppBar(title: ZefyrLogo()), body: ListView( children: [ @@ -50,8 +51,9 @@ class _ViewScreen extends State { child: ZefyrView( document: doc, imageDelegate: CustomImageDelegate(), + videoDelegate: CustomVideoDelegate(), ), - ) + ), ], ), ); diff --git a/packages/zefyr/example/pubspec.yaml b/packages/zefyr/example/pubspec.yaml index e914eaea8..01ad113e4 100644 --- a/packages/zefyr/example/pubspec.yaml +++ b/packages/zefyr/example/pubspec.yaml @@ -9,6 +9,9 @@ description: A new Flutter project. # Read more about versioning at semver.org. version: 1.0.0+1 +environment: + sdk: ">=2.2.0 <3.0.0" + dependencies: flutter: sdk: flutter @@ -19,6 +22,7 @@ dependencies: image_picker: ^0.6.1 zefyr: path: ../ + video_player: ^1.0.1 dependency_overrides: notus: @@ -43,6 +47,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - images/breeze.jpg + - video/nature_videos_UCDQ_Stczw0.mp4 # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.io/assets-and-images/#resolution-aware. diff --git a/packages/zefyr/example/video/nature_videos_UCDQ_Stczw0.mp4 b/packages/zefyr/example/video/nature_videos_UCDQ_Stczw0.mp4 new file mode 100644 index 000000000..695cf5142 Binary files /dev/null and b/packages/zefyr/example/video/nature_videos_UCDQ_Stczw0.mp4 differ diff --git a/packages/zefyr/lib/src/widgets/__theme.dart b/packages/zefyr/lib/src/widgets/__theme.dart index 15c806bfb..02405292f 100644 --- a/packages/zefyr/lib/src/widgets/__theme.dart +++ b/packages/zefyr/lib/src/widgets/__theme.dart @@ -55,6 +55,7 @@ class ZefyrTheme extends InheritedWidget { class ZefyrThemeData { final TextStyle boldStyle; final TextStyle italicStyle; + final TextStyle underlineStyle; final TextStyle linkStyle; final StyleTheme paragraphTheme; final HeadingTheme headingTheme; @@ -76,12 +77,14 @@ class ZefyrThemeData { const padding = EdgeInsets.symmetric(vertical: 8.0); final boldStyle = TextStyle(fontWeight: FontWeight.bold); final italicStyle = TextStyle(fontStyle: FontStyle.italic); + final underlineStyle = TextStyle(decoration: TextDecoration.underline); final linkStyle = TextStyle( color: themeData.accentColor, decoration: TextDecoration.underline); return ZefyrThemeData( boldStyle: boldStyle, italicStyle: italicStyle, + underlineStyle: underlineStyle, linkStyle: linkStyle, paragraphTheme: StyleTheme(textStyle: paragraphStyle, padding: padding), headingTheme: HeadingTheme.fallback(context), @@ -96,6 +99,7 @@ class ZefyrThemeData { const ZefyrThemeData({ this.boldStyle, this.italicStyle, + this.underlineStyle, this.linkStyle, this.paragraphTheme, this.headingTheme, @@ -110,6 +114,7 @@ class ZefyrThemeData { TextStyle textStyle, TextStyle boldStyle, TextStyle italicStyle, + TextStyle underlineStyle, TextStyle linkStyle, StyleTheme paragraphTheme, HeadingTheme headingTheme, @@ -122,6 +127,7 @@ class ZefyrThemeData { return ZefyrThemeData( boldStyle: boldStyle ?? this.boldStyle, italicStyle: italicStyle ?? this.italicStyle, + underlineStyle: underlineStyle ?? this.underlineStyle, linkStyle: linkStyle ?? this.linkStyle, paragraphTheme: paragraphTheme ?? this.paragraphTheme, headingTheme: headingTheme ?? this.headingTheme, @@ -137,6 +143,7 @@ class ZefyrThemeData { return copyWith( boldStyle: other.boldStyle, italicStyle: other.italicStyle, + underlineStyle: other.underlineStyle, linkStyle: other.linkStyle, paragraphTheme: other.paragraphTheme, headingTheme: other.headingTheme, @@ -215,11 +222,27 @@ class BlockTheme { /// Style theme for quotes. final StyleTheme quote; + /// Style theme for align left + final StyleTheme alignLeft; + + /// Style theme for align right + final StyleTheme alignRight; + + /// Style theme for align center + final StyleTheme alignCenter; + + /// Style theme for align justify + final StyleTheme alignJustify; + BlockTheme({ @required this.bulletList, @required this.numberList, @required this.quote, @required this.code, + @required this.alignLeft, + @required this.alignRight, + @required this.alignCenter, + @required this.alignJustify, }); /// Creates fallback theme for blocks. @@ -244,6 +267,10 @@ class BlockTheme { return BlockTheme( bulletList: StyleTheme(padding: padding), numberList: StyleTheme(padding: padding), + alignLeft: StyleTheme(padding: padding), + alignRight: StyleTheme(padding: padding), + alignJustify: StyleTheme(padding: padding), + alignCenter: StyleTheme(padding: padding), quote: StyleTheme( textStyle: TextStyle( color: defaultTextStyle.style.color.withOpacity(0.6), @@ -265,7 +292,7 @@ class BlockTheme { /// Theme for a specific attribute style. /// -/// Used in [HeadingTheme] and [BlockTheme], as well as in +/// Used in [HeadingTheme], [AlignTheme] and [BlockTheme], as well as in /// [ZefyrThemeData.paragraphTheme]. class StyleTheme { /// Text style of this theme. diff --git a/packages/zefyr/lib/src/widgets/align.dart b/packages/zefyr/lib/src/widgets/align.dart new file mode 100644 index 000000000..3c917ac76 --- /dev/null +++ b/packages/zefyr/lib/src/widgets/align.dart @@ -0,0 +1,76 @@ +// Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'package:flutter/material.dart'; +import 'package:notus/notus.dart'; + +import 'paragraph.dart'; +import 'theme.dart'; + +/// Represents number lists and bullet lists in a Zefyr editor. +class ZefyrAlign extends StatelessWidget { + const ZefyrAlign({Key key, @required this.node}) : super(key: key); + + final BlockNode node; + + TextAlign _getTextAlign() { + final blockStyle = node.style.get(NotusAttribute.block); + if (blockStyle == NotusAttribute.block.alignLeft) { + return TextAlign.left; + } else if (blockStyle == NotusAttribute.block.alignRight) { + return TextAlign.right; + } else if (blockStyle == NotusAttribute.block.alignCenter) { + return TextAlign.center; + } else if (blockStyle == NotusAttribute.block.alignJustify) { + return TextAlign.justify; + } + return TextAlign.start; + } + + @override + Widget build(BuildContext context) { + final theme = ZefyrTheme.of(context); + List items = []; + for (var line in node.children) { + items.add(_buildLine( + line, + null, + theme.indentWidth, + )); + } + + return Column(children: items); + } + + Widget _buildLine(Node node, TextStyle blockStyle, double indentSize) { + LineNode line = node; + final textAlign = _getTextAlign(); + Widget content; + + if (line.style.contains(NotusAttribute.heading)) { + content = ZefyrHeading( + node: line, + blockStyle: blockStyle, + textAlign: textAlign, + ); + } else { + content = ZefyrParagraph( + node: line, + blockStyle: blockStyle, + textAlign: textAlign, + ); + } + + /// Uniquekey is needed because if the user switches from one alignment to another + /// it won't change immediately, since the change in alignment is change in style + /// flutter won't reflect it automatically, so generate a unique key each time. + return Row( + key: ValueKey(textAlign), + children: [ + Expanded( + child: content, + ), + ], + ); + } +} diff --git a/packages/zefyr/lib/src/widgets/buttons.dart b/packages/zefyr/lib/src/widgets/buttons.dart index 6f7ab2c7f..2a74bc052 100644 --- a/packages/zefyr/lib/src/widgets/buttons.dart +++ b/packages/zefyr/lib/src/widgets/buttons.dart @@ -110,11 +110,16 @@ class ZefyrButton extends StatelessWidget { return onPressed; } else if (isAttributeAction) { final attribute = kZefyrToolbarAttributeActions[action]; + if (attribute is NotusAttribute) { return () => _toggleAttribute(attribute, editor); } } else if (action == ZefyrToolbarAction.close) { return () => toolbar.closeOverlay(); + } else if (action == ZefyrToolbarAction.undo) { + return () => editor.undo(); + } else if (action == ZefyrToolbarAction.redo) { + return () => editor.redo(); } else if (action == ZefyrToolbarAction.hideKeyboard) { return () => editor.hideKeyboard(); } @@ -124,6 +129,7 @@ class ZefyrButton extends StatelessWidget { void _toggleAttribute(NotusAttribute attribute, ZefyrScope editor) { final isToggled = editor.selectionStyle.containsSame(attribute); + if (isToggled) { editor.formatSelection(attribute.unset); } else { @@ -235,10 +241,208 @@ class _HeadingButtonState extends State { } } -/// Controls image attribute. +/// Controls align styles. /// -/// When pressed, this button displays overlay toolbar with three -/// buttons for each heading level. +/// When pressed, this button displays overlay toolbar with four +/// buttons for each align style. +class AlignButton extends StatefulWidget { + const AlignButton({Key key}) : super(key: key); + + @override + _AlignButtonState createState() => _AlignButtonState(); +} + +class _AlignButtonState extends State { + @override + Widget build(BuildContext context) { + final toolbar = ZefyrToolbar.of(context); + return toolbar.buildButton( + context, + ZefyrToolbarAction.align, + onPressed: showOverlay, + ); + } + + void showOverlay() { + final toolbar = ZefyrToolbar.of(context); + toolbar.showOverlay(buildOverlay); + } + + Widget buildOverlay(BuildContext context) { + final toolbar = ZefyrToolbar.of(context); + final buttons = Row( + children: [ + SizedBox(width: 8.0), + toolbar.buildButton(context, ZefyrToolbarAction.alignLeft), + toolbar.buildButton(context, ZefyrToolbarAction.alignRight), + toolbar.buildButton(context, ZefyrToolbarAction.alignCenter), + toolbar.buildButton(context, ZefyrToolbarAction.alignJustify), + ], + ); + return ZefyrToolbarScaffold(body: buttons); + } +} + +/// Controls video attribute. +class VideoButton extends StatefulWidget { + const VideoButton({Key key}) : super(key: key); + + @override + _VideoButtonState createState() => _VideoButtonState(); +} + +class _VideoButtonState extends State { + String youtubeVideo; + ValueNotifier isPaste = ValueNotifier(true); + TextEditingController textEditingController = TextEditingController(); + + void _pickFromCamera() async { + final editor = ZefyrToolbar.of(context).editor; + final video = + await editor.videoDelegate.pickVideo(editor.videoDelegate.cameraSource); + if (video != null) { + editor.formatSelection(NotusAttribute.embed.video(video)); + } + } + + void _pickFromGallery() async { + final editor = ZefyrToolbar.of(context).editor; + final video = await editor.videoDelegate + .pickVideo(editor.videoDelegate.gallerySource); + if (video != null) { + editor.formatSelection(NotusAttribute.embed.video(video)); + } + } + + //TODO alternate possible for translation use Icons.done & Icons.close for OK & Cancel + void _youtubeLink() async { + final editor = ZefyrToolbar.of(context).editor; + await showDialog( + context: context, + builder: (context) { + return Center( + child: Container( + height: 200, //MediaQuery.of(context).size.height * .2, + width: MediaQuery.of(context).size.width * 0.95, + //padding: EdgeInsets.all(20), + margin: MediaQuery.of(context).viewInsets, + child: Material( + child: Container( + padding: EdgeInsets.all(20), + child: ValueListenableBuilder( + valueListenable: isPaste, + builder: (context, currentState, child) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TextField( + autofocus: true, + controller: textEditingController, + decoration: InputDecoration( + suffixIcon: isPaste.value + ? IconButton( + icon: Icon( + Icons.paste, + color: + Theme.of(context).primaryColor, + ), + onPressed: () async { + final data = + await Clipboard.getData( + Clipboard.kTextPlain); + if (data.text.isNotEmpty) { + isPaste.value = !isPaste.value; + textEditingController.text = + data.text; + } + }, + ) + : IconButton( + icon: Icon( + Icons.clear, + color: Colors.red, + ), + onPressed: () async { + isPaste.value = !isPaste.value; + textEditingController.text = ''; + }, + ), + hintText: 'Add a Youtube Link here'), + ), + SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + FlatButton( + color: Theme.of(context).primaryColor, + onPressed: () { + youtubeVideo = null; + Navigator.pop(context); + }, + child: Text('Cancel', + style: TextStyle(color: Colors.white)), + ), + FlatButton( + color: Theme.of(context).primaryColor, + onPressed: () { + youtubeVideo = textEditingController.text; + Navigator.pop(context); + }, + child: Text( + 'OK', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ], + ); + }), + ), + ), + ), + ); + }); + final video = youtubeVideo; + if (video != null) { + editor.formatSelection(NotusAttribute.embed.video(video)); + } + } + + Widget buildOverlay(BuildContext context) { + final toolbar = ZefyrToolbar.of(context); + final buttons = Row( + children: [ + SizedBox(width: 8.0), + toolbar.buildButton(context, ZefyrToolbarAction.cameraImage, + onPressed: _pickFromCamera), + toolbar.buildButton(context, ZefyrToolbarAction.galleryImage, + onPressed: _pickFromGallery), + toolbar.buildButton(context, ZefyrToolbarAction.link, + onPressed: _youtubeLink) + ], + ); + return ZefyrToolbarScaffold(body: buttons); + } + + void showOverlay() { + final toolbar = ZefyrToolbar.of(context); + toolbar.showOverlay(buildOverlay); + } + + @override + Widget build(BuildContext context) { + final toolbar = ZefyrToolbar.of(context); + return toolbar.buildButton( + context, + ZefyrToolbarAction.video, + onPressed: showOverlay, + ); + } +} + +/// Controls image attribute. class ImageButton extends StatefulWidget { const ImageButton({Key key}) : super(key: key); @@ -348,7 +552,7 @@ class _LinkButtonState extends State { final toolbar = ZefyrToolbar.of(context); setState(() { _inputKey = UniqueKey(); - _inputController.text = getLink('https://'); + _inputController.text = getLink(""); _inputController.addListener(_handleInputChange); toolbar.markNeedsRebuild(); }); @@ -359,18 +563,10 @@ class _LinkButtonState extends State { setState(() { var error = false; if (_inputController.text.isNotEmpty) { - try { - var uri = Uri.parse(_inputController.text); - if ((uri.isScheme('https') || uri.isScheme('http')) && - uri.host.isNotEmpty) { - toolbar.editor.formatSelection( - NotusAttribute.link.fromString(_inputController.text)); - } else { - error = true; - } - } on FormatException { - error = true; - } + toolbar.editor.formatSelection( + NotusAttribute.link.fromString(_inputController.text)); + } else { + error = true; } if (error) { _formatError = error; @@ -567,8 +763,7 @@ class _LinkView extends StatelessWidget { value, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.textTheme.subtitle1 - .copyWith(color: toolbarTheme.disabledIconColor), + style: theme.textTheme.subtitle1.copyWith(color: Colors.white), ), ) ], diff --git a/packages/zefyr/lib/src/widgets/common.dart b/packages/zefyr/lib/src/widgets/common.dart index 633f296e8..6b97e3715 100644 --- a/packages/zefyr/lib/src/widgets/common.dart +++ b/packages/zefyr/lib/src/widgets/common.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:notus/notus.dart'; +import 'package:zefyr/src/widgets/video.dart'; import 'editable_box.dart'; import 'horizontal_rule.dart'; @@ -16,8 +17,13 @@ import 'theme.dart'; /// Represents single line of rich text document in Zefyr editor. class ZefyrLine extends StatefulWidget { - const ZefyrLine({Key key, @required this.node, this.style, this.padding}) - : assert(node != null), + const ZefyrLine({ + Key key, + @required this.node, + this.style, + this.padding, + this.textAlign, + }) : assert(node != null), super(key: key); /// Line in the document represented by this widget. @@ -30,6 +36,9 @@ class ZefyrLine extends StatefulWidget { /// Padding to add around this paragraph. final EdgeInsets padding; + /// TextAlign to add around the block of lines + final TextAlign textAlign; + @override _ZefyrLineState createState() => _ZefyrLineState(); } @@ -40,6 +49,7 @@ class _ZefyrLineState extends State { @override Widget build(BuildContext context) { final scope = ZefyrScope.of(context); + if (scope.isEditable) { ensureVisible(context, scope); } @@ -53,6 +63,7 @@ class _ZefyrLineState extends State { content = ZefyrRichText( node: widget.node, text: buildText(context), + textAlign: widget.textAlign ?? TextAlign.start, ); } @@ -101,14 +112,14 @@ class _ZefyrLineState extends State { } void bringIntoView(BuildContext context) { - ScrollableState scrollable = Scrollable.of(context); + final scrollable = Scrollable.of(context); final object = context.findRenderObject(); assert(object.attached); - final RenderAbstractViewport viewport = RenderAbstractViewport.of(object); + final viewport = RenderAbstractViewport.of(object); assert(viewport != null); - final double offset = scrollable.position.pixels; - double target = viewport.getOffsetToReveal(object, 0.0).offset; + final offset = scrollable.position.pixels; + var target = viewport.getOffsetToReveal(object, 0.0).offset; if (target - offset < 0.0) { scrollable.position.jumpTo(target); return; @@ -121,7 +132,7 @@ class _ZefyrLineState extends State { TextSpan buildText(BuildContext context) { final theme = ZefyrTheme.of(context); - final List children = widget.node.children + final children = widget.node.children .map((node) => _segmentToTextSpan(node, theme)) .toList(growable: false); return TextSpan(style: widget.style, children: children); @@ -138,7 +149,7 @@ class _ZefyrLineState extends State { } TextStyle _getTextStyle(NotusStyle style, ZefyrThemeData theme) { - TextStyle result = TextStyle(); + var result = TextStyle(); if (style.containsSame(NotusAttribute.bold)) { result = result.merge(theme.attributeTheme.bold); } @@ -148,6 +159,9 @@ class _ZefyrLineState extends State { if (style.contains(NotusAttribute.link)) { result = result.merge(theme.attributeTheme.link); } + if (style.contains(NotusAttribute.underline)) { + result = result.merge(theme.attributeTheme.underline); + } return result; } @@ -159,6 +173,8 @@ class _ZefyrLineState extends State { return ZefyrHorizontalRule(node: node); } else if (embed.type == EmbedType.image) { return ZefyrImage(node: node, delegate: scope.imageDelegate); + } else if (embed.type == EmbedType.video) { + return ZefyrVideo(node: node, delegate: scope.videoDelegate); } else { throw UnimplementedError('Unimplemented embed type ${embed.type}'); } diff --git a/packages/zefyr/lib/src/widgets/controller.dart b/packages/zefyr/lib/src/widgets/controller.dart index e3152defd..8107bd515 100644 --- a/packages/zefyr/lib/src/widgets/controller.dart +++ b/packages/zefyr/lib/src/widgets/controller.dart @@ -168,7 +168,8 @@ class ZefyrController extends ChangeNotifier { if (length == 0 && (attribute.key == NotusAttribute.bold.key || - attribute.key == NotusAttribute.italic.key)) { + attribute.key == NotusAttribute.italic.key || + attribute.key == NotusAttribute.underline.key)) { // Add the attribute to our toggledStyle. It will be used later upon insertion. _toggledStyles = toggledStyles.put(attribute); } @@ -193,6 +194,16 @@ class ZefyrController extends ChangeNotifier { formatText(index, length, attribute); } + void undo() { + document.undo(); + updateSelection(TextSelection.collapsed(offset: document.length)); + } + + void redo() { + document.redo(); + updateSelection(TextSelection.collapsed(offset: document.length)); + } + /// Returns style of specified text range. /// /// If nothing is selected but we've toggled an attribute, diff --git a/packages/zefyr/lib/src/widgets/editable_text.dart b/packages/zefyr/lib/src/widgets/editable_text.dart index ff44cfcc6..9ee5d64b0 100644 --- a/packages/zefyr/lib/src/widgets/editable_text.dart +++ b/packages/zefyr/lib/src/widgets/editable_text.dart @@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:notus/notus.dart'; +import 'package:zefyr/src/widgets/video.dart'; import 'code.dart'; import 'common.dart'; @@ -21,6 +22,7 @@ import 'render_context.dart'; import 'scope.dart'; import 'selection.dart'; import 'theme.dart'; +import 'align.dart'; /// Core widget responsible for editing Zefyr documents. /// @@ -35,6 +37,7 @@ class ZefyrEditableText extends StatefulWidget { @required this.controller, @required this.focusNode, @required this.imageDelegate, + @required this.videoDelegate, this.selectionControls, this.autofocus = true, this.mode = ZefyrMode.edit, @@ -53,6 +56,7 @@ class ZefyrEditableText extends StatefulWidget { /// Controls whether this editor has keyboard focus. final FocusNode focusNode; final ZefyrImageDelegate imageDelegate; + final ZefyrVideoDelegate videoDelegate; /// Whether this text field should focus itself if nothing else is already /// focused. @@ -239,7 +243,9 @@ class _ZefyrEditableTextState extends State Widget _defaultChildBuilder(BuildContext context, Node node) { if (node is LineNode) { if (node.hasEmbed) { - return ZefyrLine(node: node); + return ZefyrLine( + node: node, + ); } else if (node.style.contains(NotusAttribute.heading)) { return ZefyrHeading(node: node); } @@ -248,6 +254,7 @@ class _ZefyrEditableTextState extends State final BlockNode block = node; final blockStyle = block.style.get(NotusAttribute.block); + if (blockStyle == NotusAttribute.block.code) { return ZefyrCode(node: block); } else if (blockStyle == NotusAttribute.block.bulletList) { @@ -256,8 +263,15 @@ class _ZefyrEditableTextState extends State return ZefyrList(node: block); } else if (blockStyle == NotusAttribute.block.quote) { return ZefyrQuote(node: block); + } else if (blockStyle == NotusAttribute.block.alignLeft) { + return ZefyrAlign(node: block); + } else if (blockStyle == NotusAttribute.block.alignRight) { + return ZefyrAlign(node: block); + } else if (blockStyle == NotusAttribute.block.alignCenter) { + return ZefyrAlign(node: block); + } else if (blockStyle == NotusAttribute.block.alignJustify) { + return ZefyrAlign(node: block); } - throw UnimplementedError('Block format $blockStyle.'); } diff --git a/packages/zefyr/lib/src/widgets/editor.dart b/packages/zefyr/lib/src/widgets/editor.dart index a9b873e69..ceedaeafa 100644 --- a/packages/zefyr/lib/src/widgets/editor.dart +++ b/packages/zefyr/lib/src/widgets/editor.dart @@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:zefyr/src/widgets/video.dart'; import 'controller.dart'; import 'editable_text.dart'; @@ -25,6 +26,7 @@ class ZefyrEditor extends StatefulWidget { this.padding = const EdgeInsets.symmetric(horizontal: 16.0), this.toolbarDelegate, this.imageDelegate, + this.videoDelegate, this.selectionControls, this.physics, this.keyboardAppearance, @@ -59,6 +61,8 @@ class ZefyrEditor extends StatefulWidget { /// This delegate is required if embedding images is allowed. final ZefyrImageDelegate imageDelegate; + final ZefyrVideoDelegate videoDelegate; + /// Optional delegate for building the text selection handles and toolbar. /// /// If not provided then platform-specific implementation is used by default. @@ -83,6 +87,7 @@ class ZefyrEditor extends StatefulWidget { class _ZefyrEditorState extends State { ZefyrImageDelegate _imageDelegate; + ZefyrVideoDelegate _videoDelegate; ZefyrScope _scope; ZefyrThemeData _themeData; GlobalKey _toolbarKey; @@ -130,6 +135,7 @@ class _ZefyrEditorState extends State { void initState() { super.initState(); _imageDelegate = widget.imageDelegate; + _videoDelegate = widget.videoDelegate; } @override @@ -142,6 +148,11 @@ class _ZefyrEditorState extends State { _imageDelegate = widget.imageDelegate; _scope.imageDelegate = _imageDelegate; } + + if (widget.videoDelegate != oldWidget.videoDelegate) { + _videoDelegate = widget.videoDelegate; + _scope.videoDelegate = _videoDelegate; + } } @override @@ -157,6 +168,7 @@ class _ZefyrEditorState extends State { _scope = ZefyrScope.editable( mode: widget.mode, imageDelegate: _imageDelegate, + videoDelegate: _videoDelegate, controller: widget.controller, focusNode: widget.focusNode, focusScope: FocusScope.of(context), @@ -194,6 +206,7 @@ class _ZefyrEditorState extends State { controller: _scope.controller, focusNode: _scope.focusNode, imageDelegate: _scope.imageDelegate, + videoDelegate: _scope.videoDelegate, selectionControls: widget.selectionControls, autofocus: widget.autofocus, mode: widget.mode, diff --git a/packages/zefyr/lib/src/widgets/field.dart b/packages/zefyr/lib/src/widgets/field.dart index cf05b6a09..69842c309 100644 --- a/packages/zefyr/lib/src/widgets/field.dart +++ b/packages/zefyr/lib/src/widgets/field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:zefyr/src/widgets/video.dart'; import 'controller.dart'; import 'editor.dart'; @@ -19,6 +20,7 @@ class ZefyrField extends StatefulWidget { final ZefyrMode mode; final ZefyrToolbarDelegate toolbarDelegate; final ZefyrImageDelegate imageDelegate; + final ZefyrVideoDelegate videoDelegate; final ScrollPhysics physics; /// The appearance of the keyboard. @@ -38,6 +40,7 @@ class ZefyrField extends StatefulWidget { this.mode, this.toolbarDelegate, this.imageDelegate, + this.videoDelegate, this.physics, this.keyboardAppearance, }) : super(key: key); @@ -58,6 +61,7 @@ class _ZefyrFieldState extends State { mode: _effectiveMode, toolbarDelegate: widget.toolbarDelegate, imageDelegate: widget.imageDelegate, + videoDelegate: widget.videoDelegate, physics: widget.physics, keyboardAppearance: widget.keyboardAppearance, ); diff --git a/packages/zefyr/lib/src/widgets/image.dart b/packages/zefyr/lib/src/widgets/image.dart index 49477869d..99c9cfc28 100644 --- a/packages/zefyr/lib/src/widgets/image.dart +++ b/packages/zefyr/lib/src/widgets/image.dart @@ -220,7 +220,7 @@ class RenderEditableImage extends RenderBox minWidth: 0.0, maxWidth: width, minHeight: 0.0, - maxHeight: (width * 9 / 16).floorToDouble(), + maxHeight: double.infinity, ); child.layout(childConstraints, parentUsesSize: true); _lastChildSize = child.size; diff --git a/packages/zefyr/lib/src/widgets/input.dart b/packages/zefyr/lib/src/widgets/input.dart index e40647b89..b0a310962 100644 --- a/packages/zefyr/lib/src/widgets/input.dart +++ b/packages/zefyr/lib/src/widgets/input.dart @@ -15,6 +15,19 @@ class InputConnectionController implements TextInputClient { // // public members // + //todo: fill this + @override + void showAutocorrectionPromptRect(int start, int end) { + throw UnimplementedError(); + } + + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); + + @override + AutofillScope get currentAutofillScope { + throw UnimplementedError(); + } final RemoteValueChanged onValueChanged; diff --git a/packages/zefyr/lib/src/widgets/mode.dart b/packages/zefyr/lib/src/widgets/mode.dart index b658c9f50..a7cb70b55 100644 --- a/packages/zefyr/lib/src/widgets/mode.dart +++ b/packages/zefyr/lib/src/widgets/mode.dart @@ -5,7 +5,7 @@ import 'package:quiver_hashcode/hashcode.dart'; /// // TODO: consider extending with following: // - linkTapBehavior: none|launch -// - allowedStyles: ['bold', 'italic', 'image', ... ] +// - allowedStyles: ['bold', 'italic', 'image', 'underline', ... ] class ZefyrMode { /// Editing mode provides full access to all editing features: keyboard, /// editor toolbar with formatting tools, selection controls and selection diff --git a/packages/zefyr/lib/src/widgets/paragraph.dart b/packages/zefyr/lib/src/widgets/paragraph.dart index f6b6c8947..9820f9d5d 100644 --- a/packages/zefyr/lib/src/widgets/paragraph.dart +++ b/packages/zefyr/lib/src/widgets/paragraph.dart @@ -9,47 +9,54 @@ import 'theme.dart'; /// Represents regular paragraph line in a Zefyr editor. class ZefyrParagraph extends StatelessWidget { - ZefyrParagraph({Key key, @required this.node, this.blockStyle}) + ZefyrParagraph( + {Key key, @required this.node, this.blockStyle, this.textAlign}) : super(key: key); final LineNode node; final TextStyle blockStyle; + final TextAlign textAlign; @override Widget build(BuildContext context) { final theme = ZefyrTheme.of(context); - TextStyle style = theme.defaultLineTheme.textStyle; + var style = theme.defaultLineTheme.textStyle; if (blockStyle != null) { style = style.merge(blockStyle); } + return ZefyrLine( node: node, style: style, padding: theme.defaultLineTheme.padding, + textAlign: textAlign, ); } } /// Represents heading-styled line in [ZefyrEditor]. class ZefyrHeading extends StatelessWidget { - ZefyrHeading({Key key, @required this.node, this.blockStyle}) + ZefyrHeading({Key key, @required this.node, this.blockStyle, this.textAlign}) : assert(node.style.contains(NotusAttribute.heading)), super(key: key); final LineNode node; final TextStyle blockStyle; + final TextAlign textAlign; @override Widget build(BuildContext context) { final theme = themeOf(node, context); - TextStyle style = theme.textStyle; + var style = theme.textStyle; if (blockStyle != null) { style = style.merge(blockStyle); } + return ZefyrLine( node: node, style: style, padding: theme.padding, + textAlign: textAlign, ); } diff --git a/packages/zefyr/lib/src/widgets/quote.dart b/packages/zefyr/lib/src/widgets/quote.dart index f6e3f720b..7f9a66b28 100644 --- a/packages/zefyr/lib/src/widgets/quote.dart +++ b/packages/zefyr/lib/src/widgets/quote.dart @@ -17,7 +17,7 @@ class ZefyrQuote extends StatelessWidget { Widget build(BuildContext context) { final theme = ZefyrTheme.of(context); final style = theme.attributeTheme.quote.textStyle; - List items = []; + final List items = []; for (var line in node.children) { items.add(_buildLine(line, style, theme.indentWidth)); } diff --git a/packages/zefyr/lib/src/widgets/rich_text.dart b/packages/zefyr/lib/src/widgets/rich_text.dart index 6154a2d66..06f9e15e3 100644 --- a/packages/zefyr/lib/src/widgets/rich_text.dart +++ b/packages/zefyr/lib/src/widgets/rich_text.dart @@ -16,10 +16,12 @@ class ZefyrRichText extends LeafRenderObjectWidget { ZefyrRichText({ @required this.node, @required this.text, + @required this.textAlign, }) : assert(node != null && text != null); final LineNode node; final TextSpan text; + final TextAlign textAlign; @override RenderObject createRenderObject(BuildContext context) { @@ -27,6 +29,7 @@ class ZefyrRichText extends LeafRenderObjectWidget { text, node: node, textDirection: Directionality.of(context), + textAlign: textAlign, ); } @@ -44,7 +47,7 @@ class RenderZefyrParagraph extends RenderParagraph RenderZefyrParagraph( TextSpan text, { @required LineNode node, - TextAlign textAlign = TextAlign.start, + @required TextAlign textAlign, @required TextDirection textDirection, bool softWrap = true, TextOverflow overflow = TextOverflow.clip, @@ -67,6 +70,7 @@ class RenderZefyrParagraph extends RenderParagraph maxLines: maxLines, ); + @override LineNode node; @override diff --git a/packages/zefyr/lib/src/widgets/scope.dart b/packages/zefyr/lib/src/widgets/scope.dart index 9acfcc126..3cd9120f0 100644 --- a/packages/zefyr/lib/src/widgets/scope.dart +++ b/packages/zefyr/lib/src/widgets/scope.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:notus/notus.dart'; +import 'package:zefyr/src/widgets/video.dart'; import 'controller.dart'; import 'cursor_timer.dart'; @@ -25,10 +26,12 @@ class ZefyrScope extends ChangeNotifier { /// Creates a view-only scope. /// /// Normally used in [ZefyrView]. - ZefyrScope.view({ZefyrImageDelegate imageDelegate}) + ZefyrScope.view( + {ZefyrImageDelegate imageDelegate, ZefyrVideoDelegate videoDelegate}) : isEditable = false, _mode = ZefyrMode.view, - _imageDelegate = imageDelegate; + _imageDelegate = imageDelegate, + _videoDelegate = videoDelegate; /// Creates editable scope. /// @@ -39,6 +42,7 @@ class ZefyrScope extends ChangeNotifier { @required FocusNode focusNode, @required FocusScopeNode focusScope, ZefyrImageDelegate imageDelegate, + ZefyrVideoDelegate videoDelegate, }) : assert(mode != null), assert(controller != null), assert(focusNode != null), @@ -47,6 +51,7 @@ class ZefyrScope extends ChangeNotifier { _mode = mode, _controller = controller, _imageDelegate = imageDelegate, + _videoDelegate = videoDelegate, _focusNode = focusNode, _focusScope = focusScope, _cursorTimer = CursorTimer(), @@ -72,6 +77,15 @@ class ZefyrScope extends ChangeNotifier { } } + ZefyrVideoDelegate _videoDelegate; + ZefyrVideoDelegate get videoDelegate => _videoDelegate; + set videoDelegate(ZefyrVideoDelegate value) { + if (_videoDelegate != value) { + _videoDelegate = value; + notifyListeners(); + } + } + ZefyrMode _mode; ZefyrMode get mode => _mode; set mode(ZefyrMode value) { @@ -181,6 +195,18 @@ class ZefyrScope extends ChangeNotifier { _controller.formatSelection(value); } + void undo() { + assert(isEditable); + assert(!_disposed); + _controller.undo(); + } + + void redo() { + assert(isEditable); + assert(!_disposed); + _controller.redo(); + } + void focus() { assert(isEditable); assert(!_disposed); diff --git a/packages/zefyr/lib/src/widgets/selection.dart b/packages/zefyr/lib/src/widgets/selection.dart index 081a2919f..19bc09cfd 100644 --- a/packages/zefyr/lib/src/widgets/selection.dart +++ b/packages/zefyr/lib/src/widgets/selection.dart @@ -102,7 +102,7 @@ class _ZefyrSelectionOverlayState extends State } @override - void hideToolbar() { + void hideToolbar([bool hideHandles = true]) { _didCaretTap = false; // reset double tap. _toolbar?.remove(); _toolbar = null; @@ -273,8 +273,13 @@ class _ZefyrSelectionOverlayState extends State _longPressPosition = null; HitTestResult result = HitTestResult(); WidgetsBinding.instance.hitTest(result, globalPoint); - final box = _getEditableBox(result); + var box = _getEditableBox(result); if (box == null) { + if (isToolbarVisible) { + hideToolbar(); + } else { + showToolbar(); + } return; } final localPoint = box.globalToLocal(globalPoint); @@ -284,6 +289,13 @@ class _ZefyrSelectionOverlayState extends State baseOffset: word.start, extentOffset: word.end, ); + if (selection.baseOffset == 0 && selection.extentOffset == 0) { + if (isToolbarVisible) { + hideToolbar(); + } else { + showToolbar(); + } + } _scope.controller.updateSelection(selection, source: ChangeSource.local); } @@ -298,6 +310,12 @@ class _ZefyrSelectionOverlayState extends State @override bool get selectAllEnabled => _scope.mode.canSelect; + + @override + void userUpdateTextEditingValue( + TextEditingValue value, SelectionChangedCause cause) { + // TODO: implement userUpdateTextEditingValue + } } enum _SelectionHandlePosition { base, extent } @@ -597,7 +615,8 @@ class _SelectionToolbarState extends State<_SelectionToolbar> { block.preferredLineHeight, midpoint, endpoints, - widget.selectionOverlay); + widget.selectionOverlay, + null); return CompositedTransformFollower( link: block.layerLink, showWhenUnlinked: false, diff --git a/packages/zefyr/lib/src/widgets/theme.dart b/packages/zefyr/lib/src/widgets/theme.dart index f90496e9e..2e12f1ee2 100644 --- a/packages/zefyr/lib/src/widgets/theme.dart +++ b/packages/zefyr/lib/src/widgets/theme.dart @@ -142,7 +142,7 @@ class ZefyrThemeData { /// Holds typography values for a document line in Zefyr editor. /// -/// Applicable for regular paragraphs, headings and lines within blocks +/// Applicable for regular paragraphs, headings, align and lines within blocks /// (lists, quotes). Blocks may override some of these values using [BlockTheme]. @immutable class LineTheme { @@ -291,6 +291,9 @@ class AttributeTheme { /// Style used to render text containing links. final TextStyle link; + // Style theme used to render underline blocks + final TextStyle underline; + /// Style theme used to render largest headings. final LineTheme heading1; @@ -309,6 +312,12 @@ class AttributeTheme { /// Style theme used to render quote blocks. final BlockTheme quote; + /// Style theme used to render align block. + final BlockTheme alignLeft; + final BlockTheme alignRight; + final BlockTheme alignCenter; + final BlockTheme alignJustify; + /// Style theme used to render code blocks. final BlockTheme code; @@ -316,6 +325,7 @@ class AttributeTheme { AttributeTheme({ this.bold, this.italic, + this.underline, this.link, this.heading1, this.heading2, @@ -324,6 +334,10 @@ class AttributeTheme { this.numberList, this.quote, this.code, + this.alignLeft, + this.alignCenter, + this.alignJustify, + this.alignRight, }); /// The default attribute theme. @@ -347,27 +361,28 @@ class AttributeTheme { return AttributeTheme( bold: TextStyle(fontWeight: FontWeight.bold), italic: TextStyle(fontStyle: FontStyle.italic), + underline: TextStyle(decoration: TextDecoration.underline), link: TextStyle( decoration: TextDecoration.underline, color: theme.accentColor, ), heading1: LineTheme( textStyle: defaultLineTheme.textStyle.copyWith( - fontSize: 34.0, - color: defaultLineTheme.textStyle.color.withOpacity(0.7), + fontSize: 24.0, + color: Colors.black, height: 1.15, - fontWeight: FontWeight.w300, + fontWeight: FontWeight.bold, ), - padding: EdgeInsets.only(top: 16.0), + padding: EdgeInsets.only(top: 5.0), ), heading2: LineTheme( textStyle: defaultLineTheme.textStyle.copyWith( - fontSize: 24.0, - color: defaultLineTheme.textStyle.color.withOpacity(0.7), + fontSize: 20.0, + color: Colors.black, height: 1.15, - fontWeight: FontWeight.normal, + fontWeight: FontWeight.bold, ), - padding: EdgeInsets.only(top: 8.0), + padding: EdgeInsets.only(top: 5.0), ), heading3: LineTheme( textStyle: defaultLineTheme.textStyle.copyWith( @@ -393,6 +408,10 @@ class AttributeTheme { ), inheritLineTextStyle: true, ), + alignLeft: BlockTheme(), + alignRight: BlockTheme(), + alignCenter: BlockTheme(), + alignJustify: BlockTheme(), code: BlockTheme( padding: EdgeInsets.symmetric(vertical: 8.0), textStyle: TextStyle( @@ -412,6 +431,7 @@ class AttributeTheme { AttributeTheme copyWith({ TextStyle bold, TextStyle italic, + TextStyle underline, TextStyle link, LineTheme heading1, LineTheme heading2, @@ -419,11 +439,16 @@ class AttributeTheme { BlockTheme bulletList, BlockTheme numberList, BlockTheme quote, + BlockTheme alignLeft, + BlockTheme alignRight, + BlockTheme alignJustify, + BlockTheme alignCenter, BlockTheme code, }) { return AttributeTheme( bold: bold ?? this.bold, italic: italic ?? this.italic, + underline: underline ?? this.underline, link: link ?? this.link, heading1: heading1 ?? this.heading1, heading2: heading2 ?? this.heading2, @@ -431,6 +456,10 @@ class AttributeTheme { bulletList: bulletList ?? this.bulletList, numberList: numberList ?? this.numberList, quote: quote ?? this.quote, + alignLeft: alignLeft ?? this.alignLeft, + alignRight: alignRight ?? this.alignRight, + alignJustify: alignJustify ?? this.alignJustify, + alignCenter: alignCenter ?? this.alignCenter, code: code ?? this.code, ); } @@ -442,6 +471,7 @@ class AttributeTheme { return copyWith( bold: bold?.merge(other.bold) ?? other.bold, italic: italic?.merge(other.italic) ?? other.italic, + underline: underline?.merge(other.underline) ?? other.underline, link: link?.merge(other.link) ?? other.link, heading1: heading1?.merge(other.heading1) ?? other.heading1, heading2: heading2?.merge(other.heading2) ?? other.heading2, @@ -449,6 +479,11 @@ class AttributeTheme { bulletList: bulletList?.merge(other.bulletList) ?? other.bulletList, numberList: numberList?.merge(other.numberList) ?? other.numberList, quote: quote?.merge(other.quote) ?? other.quote, + alignCenter: alignCenter?.merge(other.alignCenter) ?? other.alignCenter, + alignJustify: + alignJustify?.merge(other.alignJustify) ?? other.alignJustify, + alignRight: alignRight?.merge(other.alignRight) ?? other.alignRight, + alignLeft: alignLeft?.merge(other.alignLeft) ?? other.alignLeft, code: code?.merge(other.code) ?? other.code, ); } @@ -459,6 +494,7 @@ class AttributeTheme { final AttributeTheme otherTheme = other; return (otherTheme.bold == bold) && (otherTheme.italic == italic) && + (otherTheme.underline == underline) && (otherTheme.link == link) && (otherTheme.heading1 == heading1) && (otherTheme.heading2 == heading2) && @@ -466,6 +502,10 @@ class AttributeTheme { (otherTheme.bulletList == bulletList) && (otherTheme.numberList == numberList) && (otherTheme.quote == quote) && + (otherTheme.alignLeft == alignLeft) && + (otherTheme.alignRight == alignRight) && + (otherTheme.alignCenter == alignCenter) && + (otherTheme.alignJustify == alignJustify) && (otherTheme.code == code); } @@ -474,6 +514,7 @@ class AttributeTheme { return hashList([ bold, italic, + underline, link, heading1, heading2, diff --git a/packages/zefyr/lib/src/widgets/toolbar.dart b/packages/zefyr/lib/src/widgets/toolbar.dart index 72a610327..09cb7d557 100644 --- a/packages/zefyr/lib/src/widgets/toolbar.dart +++ b/packages/zefyr/lib/src/widgets/toolbar.dart @@ -15,6 +15,7 @@ import 'theme.dart'; enum ZefyrToolbarAction { bold, italic, + underline, link, unlink, clipboardCopy, @@ -23,6 +24,11 @@ enum ZefyrToolbarAction { headingLevel1, headingLevel2, headingLevel3, + align, + alignLeft, + alignRight, + alignCenter, + alignJustify, bulletList, numberList, code, @@ -34,16 +40,24 @@ enum ZefyrToolbarAction { hideKeyboard, close, confirm, + undo, + redo, + video } final kZefyrToolbarAttributeActions = { ZefyrToolbarAction.bold: NotusAttribute.bold, ZefyrToolbarAction.italic: NotusAttribute.italic, + ZefyrToolbarAction.underline: NotusAttribute.underline, ZefyrToolbarAction.link: NotusAttribute.link, ZefyrToolbarAction.heading: NotusAttribute.heading, ZefyrToolbarAction.headingLevel1: NotusAttribute.heading.level1, ZefyrToolbarAction.headingLevel2: NotusAttribute.heading.level2, ZefyrToolbarAction.headingLevel3: NotusAttribute.heading.level3, + ZefyrToolbarAction.alignLeft: NotusAttribute.block.alignLeft, + ZefyrToolbarAction.alignRight: NotusAttribute.block.alignRight, + ZefyrToolbarAction.alignCenter: NotusAttribute.block.alignCenter, + ZefyrToolbarAction.alignJustify: NotusAttribute.block.alignJustify, ZefyrToolbarAction.bulletList: NotusAttribute.block.bulletList, ZefyrToolbarAction.numberList: NotusAttribute.block.numberList, ZefyrToolbarAction.code: NotusAttribute.block.code, @@ -252,14 +266,19 @@ class ZefyrToolbarState extends State final buttons = [ buildButton(context, ZefyrToolbarAction.bold), buildButton(context, ZefyrToolbarAction.italic), + buildButton(context, ZefyrToolbarAction.underline), + if (editor.imageDelegate != null) ImageButton(), + if (editor.videoDelegate != null) VideoButton(), LinkButton(), HeadingButton(), + AlignButton(), buildButton(context, ZefyrToolbarAction.bulletList), buildButton(context, ZefyrToolbarAction.numberList), buildButton(context, ZefyrToolbarAction.quote), buildButton(context, ZefyrToolbarAction.code), buildButton(context, ZefyrToolbarAction.horizontalRule), - if (editor.imageDelegate != null) ImageButton(), + buildButton(context, ZefyrToolbarAction.undo), + buildButton(context, ZefyrToolbarAction.redo), ]; return buttons; } @@ -337,11 +356,17 @@ class _DefaultZefyrToolbarDelegate implements ZefyrToolbarDelegate { static const kDefaultButtonIcons = { ZefyrToolbarAction.bold: Icons.format_bold, ZefyrToolbarAction.italic: Icons.format_italic, + ZefyrToolbarAction.underline: Icons.format_underlined, ZefyrToolbarAction.link: Icons.link, ZefyrToolbarAction.unlink: Icons.link_off, ZefyrToolbarAction.clipboardCopy: Icons.content_copy, ZefyrToolbarAction.openInBrowser: Icons.open_in_new, ZefyrToolbarAction.heading: Icons.format_size, + ZefyrToolbarAction.alignLeft: Icons.format_align_left, + ZefyrToolbarAction.alignRight: Icons.format_align_right, + ZefyrToolbarAction.alignCenter: Icons.format_align_center, + ZefyrToolbarAction.alignJustify: Icons.format_align_justify, + ZefyrToolbarAction.align: Icons.format_align_center, ZefyrToolbarAction.bulletList: Icons.format_list_bulleted, ZefyrToolbarAction.numberList: Icons.format_list_numbered, ZefyrToolbarAction.code: Icons.code, @@ -353,6 +378,9 @@ class _DefaultZefyrToolbarDelegate implements ZefyrToolbarDelegate { ZefyrToolbarAction.hideKeyboard: Icons.keyboard_hide, ZefyrToolbarAction.close: Icons.close, ZefyrToolbarAction.confirm: Icons.check, + ZefyrToolbarAction.undo: Icons.undo, + ZefyrToolbarAction.redo: Icons.redo, + ZefyrToolbarAction.video: Icons.movie, }; static const kSpecialIconSizes = { @@ -361,6 +389,8 @@ class _DefaultZefyrToolbarDelegate implements ZefyrToolbarDelegate { ZefyrToolbarAction.openInBrowser: 20.0, ZefyrToolbarAction.close: 20.0, ZefyrToolbarAction.confirm: 20.0, + ZefyrToolbarAction.undo: 20.0, + ZefyrToolbarAction.redo: 20.0, }; static const kDefaultButtonTexts = { diff --git a/packages/zefyr/lib/src/widgets/video.dart b/packages/zefyr/lib/src/widgets/video.dart new file mode 100644 index 000000000..d47042723 --- /dev/null +++ b/packages/zefyr/lib/src/widgets/video.dart @@ -0,0 +1,231 @@ +// Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; +import 'package:notus/notus.dart'; +import 'package:zefyr/zefyr.dart'; + +import 'editable_box.dart'; + +/// Provides interface for embedding video into Zefyr editor. +@experimental +abstract class ZefyrVideoDelegate { + /// Unique key to identify camera source. + S get cameraSource; + + /// Unique key to identify gallery source. + S get gallerySource; + + /// Builds video widget for specified video [key]. + /// + /// The [key] argument contains value which was previously returned from + /// [pickVideo] method. + Widget buildVideo(BuildContext context, String key); + + /// Picks an video from specified [source]. + /// + /// Returns unique string key for the selected video. Returned key is stored + /// in the document. + /// + /// Depending on your application returned key may represent a path to + /// an video file on user's device, an HTTP link, or an identifier generated + /// by a file hosting service like AWS S3 or Google Drive. + Future pickVideo(S source); +} + +class ZefyrVideo extends StatefulWidget { + const ZefyrVideo({Key key, @required this.node, @required this.delegate}) + : super(key: key); + + final EmbedNode node; + final ZefyrVideoDelegate delegate; + + @override + _ZefyrVideoState createState() => _ZefyrVideoState(); +} + +class _ZefyrVideoState extends State { + String get videoSource { + EmbedAttribute attribute = widget.node.style.get(NotusAttribute.embed); + return attribute.value['source'] as String; + } + + @override + Widget build(BuildContext context) { + final theme = ZefyrTheme.of(context); + final video = widget.delegate.buildVideo(context, videoSource); + return _EditableVideo( + child: Padding( + padding: theme.defaultLineTheme.padding, + child: video, + ), + node: widget.node, + ); + } +} + +class _EditableVideo extends SingleChildRenderObjectWidget { + _EditableVideo({@required Widget child, @required this.node}) + : assert(node != null), + super(child: child); + + final EmbedNode node; + + @override + RenderEditableVideo createRenderObject(BuildContext context) { + return RenderEditableVideo(node: node); + } + + @override + void updateRenderObject( + BuildContext context, RenderEditableVideo renderObject) { + renderObject..node = node; + } +} + +class RenderEditableVideo extends RenderBox + with RenderObjectWithChildMixin, RenderProxyBoxMixin + implements RenderEditableBox { + RenderEditableVideo({ + TextureBox child, + @required EmbedNode node, + }) : node = node { + this.child = child; + } + + @override + EmbedNode node; + + // TODO: Customize caret height offset instead of adjusting here by 2px. + @override + double get preferredLineHeight => size.height + 2.0; + + @override + SelectionOrder get selectionOrder => SelectionOrder.foreground; + + @override + TextSelection getLocalSelection(TextSelection documentSelection) { + if (!intersectsWithSelection(documentSelection)) return null; + + int nodeBase = node.documentOffset; + int nodeExtent = nodeBase + node.length; + int base = math.max(0, documentSelection.baseOffset - nodeBase); + int extent = + math.min(documentSelection.extentOffset, nodeExtent) - nodeBase; + return documentSelection.copyWith(baseOffset: base, extentOffset: extent); + } + + @override + List getEndpointsForSelection(TextSelection selection) { + TextSelection local = getLocalSelection(selection); + if (local.isCollapsed) { + final dx = local.extentOffset == 0 ? _childOffset.dx : size.width; + return [ + ui.TextBox.fromLTRBD(dx, 0.0, dx, size.height, TextDirection.ltr), + ]; + } + + final rect = _childRect; + return [ + ui.TextBox.fromLTRBD( + rect.left, rect.top, rect.left, rect.bottom, TextDirection.ltr), + ui.TextBox.fromLTRBD( + rect.right, rect.top, rect.right, rect.bottom, TextDirection.ltr), + ]; + } + + @override + TextPosition getPositionForOffset(Offset offset) { + int position = node.documentOffset; + + if (offset.dx > size.width / 2) { + position++; + } + return TextPosition(offset: position); + } + + @override + TextRange getWordBoundary(TextPosition position) { + final start = node.documentOffset; + return TextRange(start: start, end: start + 1); + } + + @override + bool intersectsWithSelection(TextSelection selection) { + final int base = node.documentOffset; + final int extent = base + node.length; + return base <= selection.extentOffset && selection.baseOffset <= extent; + } + + @override + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { + final pos = position.offset - node.documentOffset; + Offset caretOffset = _childOffset - Offset(kHorizontalPadding, 0.0); + if (pos == 1) { + caretOffset = + caretOffset + Offset(_lastChildSize.width + kHorizontalPadding, 0.0); + } + return caretOffset; + } + + @override + void paintSelection(PaintingContext context, Offset offset, + TextSelection selection, Color selectionColor) { + final localSelection = getLocalSelection(selection); + assert(localSelection != null); + if (!localSelection.isCollapsed) { + final Paint paint = Paint() + ..color = selectionColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0; + final rect = + Rect.fromLTWH(0.0, 0.0, _lastChildSize.width, _lastChildSize.height); + context.canvas.drawRect(rect.shift(offset + _childOffset), paint); + } + } + + void paint(PaintingContext context, Offset offset) { + super.paint(context, offset + _childOffset); + } + + static const double kHorizontalPadding = 1.0; + + Size _lastChildSize; + + Offset get _childOffset { + final dx = (size.width - _lastChildSize.width) / 2 + kHorizontalPadding; + final dy = (size.height - _lastChildSize.height) / 2; + return Offset(dx, dy); + } + + Rect get _childRect { + return Rect.fromLTWH(_childOffset.dx, _childOffset.dy, _lastChildSize.width, + _lastChildSize.height); + } + + @override + void performLayout() { + assert(constraints.hasBoundedWidth); + if (child != null) { + // Make constraints use 16:9 aspect ratio. + final width = constraints.maxWidth - kHorizontalPadding * 2; + final childConstraints = constraints.copyWith( + minWidth: 0.0, + maxWidth: width, + minHeight: 0.0, + maxHeight: 500, //double.infinity, + ); + child.layout(childConstraints, parentUsesSize: true); + _lastChildSize = child.size; + size = Size(constraints.maxWidth, _lastChildSize.height); + } else { + performResize(); + } + } +} diff --git a/packages/zefyr/lib/src/widgets/view.dart b/packages/zefyr/lib/src/widgets/view.dart index 32abedbb8..9ff8e5cc3 100644 --- a/packages/zefyr/lib/src/widgets/view.dart +++ b/packages/zefyr/lib/src/widgets/view.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:notus/notus.dart'; +import 'package:zefyr/src/widgets/video.dart'; +import 'align.dart'; import 'code.dart'; import 'common.dart'; import 'image.dart'; @@ -19,8 +21,13 @@ import 'theme.dart'; class ZefyrView extends StatefulWidget { final NotusDocument document; final ZefyrImageDelegate imageDelegate; + final ZefyrVideoDelegate videoDelegate; - const ZefyrView({Key key, @required this.document, this.imageDelegate}) + const ZefyrView( + {Key key, + @required this.document, + this.imageDelegate, + this.videoDelegate}) : super(key: key); @override @@ -32,17 +39,21 @@ class ZefyrViewState extends State { ZefyrThemeData _themeData; ZefyrImageDelegate get imageDelegate => widget.imageDelegate; + ZefyrVideoDelegate get videoDelegate => widget.videoDelegate; @override void initState() { super.initState(); - _scope = ZefyrScope.view(imageDelegate: widget.imageDelegate); + _scope = ZefyrScope.view( + imageDelegate: widget.imageDelegate, + videoDelegate: widget.videoDelegate); } @override void didUpdateWidget(ZefyrView oldWidget) { super.didUpdateWidget(oldWidget); _scope.imageDelegate = widget.imageDelegate; + _scope.videoDelegate = widget.videoDelegate; } @override @@ -103,6 +114,14 @@ class ZefyrViewState extends State { return ZefyrList(node: block); } else if (blockStyle == NotusAttribute.block.quote) { return ZefyrQuote(node: block); + } else if (blockStyle == NotusAttribute.block.alignLeft) { + return ZefyrAlign(node: block); + } else if (blockStyle == NotusAttribute.block.alignRight) { + return ZefyrAlign(node: block); + } else if (blockStyle == NotusAttribute.block.alignCenter) { + return ZefyrAlign(node: block); + } else if (blockStyle == NotusAttribute.block.alignJustify) { + return ZefyrAlign(node: block); } throw UnimplementedError('Block format $blockStyle.'); diff --git a/packages/zefyr/lib/zefyr.dart b/packages/zefyr/lib/zefyr.dart index e125dc2a5..e3c1b1a83 100644 --- a/packages/zefyr/lib/zefyr.dart +++ b/packages/zefyr/lib/zefyr.dart @@ -9,6 +9,7 @@ library zefyr; export 'package:notus/notus.dart'; +export 'src/widgets/align.dart'; export 'src/widgets/buttons.dart' hide HeadingButton, LinkButton; export 'src/widgets/code.dart'; export 'src/widgets/common.dart'; @@ -27,4 +28,5 @@ export 'src/widgets/scope.dart' hide ZefyrScopeAccess; export 'src/widgets/selection.dart' hide SelectionHandleDriver; export 'src/widgets/theme.dart'; export 'src/widgets/toolbar.dart'; +export 'src/widgets/video.dart'; export 'src/widgets/view.dart'; diff --git a/packages/zefyr/pubspec.yaml b/packages/zefyr/pubspec.yaml index 635aa4b54..1bf79ab63 100644 --- a/packages/zefyr/pubspec.yaml +++ b/packages/zefyr/pubspec.yaml @@ -5,7 +5,7 @@ author: Anatoly Pulyaevskiy homepage: https://github.com/memspace/zefyr environment: - sdk: '>=2.2.2 <3.0.0' + sdk: ">=2.2.2 <3.0.0" dependencies: flutter: @@ -13,7 +13,8 @@ dependencies: collection: ^1.14.6 url_launcher: ^5.0.0 quill_delta: ^1.0.0 - notus: ^0.1.0 + notus: + path: ../notus meta: ^1.1.0 quiver_hashcode: ^2.0.0