Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
language: dart

dart:
- stable
- dev

os:
Expand All @@ -21,7 +22,7 @@ cache:
- $HOME/.pub-cache

env:
# - FLUTTER_VERSION=beta
- FLUTTER_VERSION=beta
- FLUTTER_VERSION=dev

matrix:
Expand All @@ -37,4 +38,4 @@ script:
- pwd
- ./tool/travis.sh notus
- ./tool/travis.sh zefyr
- bash <(curl -s https://codecov.io/bash)
#- bash <(curl -s https://codecov.io/bash)
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ request or found a bug, please file it at the [issue tracker][].
* [Data Format and Document Model][data_and_document]
* [Style attributes][attributes]
* [Heuristic rules][heuristics]
* [Images][images]
* [FAQ][faq]

[quick_start]: /doc/quick_start.md
[data_and_document]: /doc/data_and_document.md
[attributes]: /doc/attributes.md
[heuristics]: /doc/heuristics.md
[images]: /doc/images.md
[faq]: /doc/faq.md

## Clean and modern look
Expand Down
12 changes: 12 additions & 0 deletions doc/heuristics.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,15 @@ When composing a change which came from a different site or server make
sure to use `ChangeSource.remote` when calling `compose()`. This allows
you to distinguish such changes from local changes made by the user
when listening on `NotusDocument.changes` stream.

### Next up

* [Images][images]

[images]: /doc/images.md

### Previous

* [Style attributes][attributes]

[attributes]: /doc/attributes.md
114 changes: 114 additions & 0 deletions doc/images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
## Images

> Note that described API is considered experimental and is likely to be
> changed in backward incompatible ways. If this happens all changes will be
> described in detail in the changelog to simplify upgrading.

Zefyr (and Notus) supports embedded images. In order to handle images in
your application you need to implement `ZefyrImageDelegate` interface which
looks like this:

```dart
abstract class ZefyrImageDelegate<S> {
/// Builds image widget for specified [imageSource] and [context].
Widget buildImage(BuildContext context, String imageSource);

/// Picks an image from specified [source].
///
/// Returns unique string key for the selected image. Returned key is stored
/// in the document.
Future<String> pickImage(S source);
}
```

Zefyr comes with default implementation which exists mostly to provide an
example and a starting point for your own version.

It is recommended to always have your own implementation specific to your
application.

### Implementing ZefyrImageDelegate

Let's start from the `pickImage` method:

```dart
// Currently Zefyr depends on image_picker plugin to show camera or image gallery.
// (note that in future versions this may change so that users can choose their
// own plugin and define custom sources)
import 'package:image_picker/image_picker.dart';

class MyAppZefyrImageDelegate implements ZefyrImageDelegate<ImageSource> {
@override
Future<String> pickImage(ImageSource source) async {
final file = await ImagePicker.pickImage(source: source);
if (file == null) return null;
// We simply return the absolute path to selected file.
return file.uri.toString();
}
}
```

This method is responsible for initiating image selection flow (either using
camera or gallery), handling result of selection and returning a string value
which essentially serves as an identifier for the image.

Returned value is stored in the document Delta and later on used to build the
appropriate `Widget`.

It is up to the developer to define what this value represents.

In the above example we simply return a full path to the file on user's device,
e.g. `file:///Users/something/something/image.jpg`. Some other examples
may include a web link, `https://myapp.com/images/some.jpg` or just some
arbitrary string like an ID.

For instance, if you upload files to your server you can initiate this task
in `pickImage`, for instance:

```dart
class MyAppZefyrImageDelegate implements ZefyrImageDelegate<ImageSource> {
final MyFileStorage storage;
MyAppZefyrImageDelegate(this.storage);

@override
Future<String> pickImage(ImageSource source) async {
final file = await ImagePicker.pickImage(source: source);
if (file == null) return null;
// Use my storage service to upload selected file. The uploadImage method
// returns unique ID of newly uploaded image on my server.
final String imageId = await storage.uploadImage(file);
return imageId;
}
}
```

Next we need to implement `buildImage`. This method takes `imageSource` argument
which contains that same string you returned from `pickImage`. Here you can
use this value to create a Flutter `Widget` which renders the image. Normally
you would return the standard `Image` widget from this method, but it is not
a requirement. You are free to create a custom widget which, for instance,
shows progress of upload operation that you initiated in the `pickImage` call.

Assuming our first example where we returned full path to the image file on
user's device, our `buildImage` method can be as simple as following:

```dart
class MyAppZefyrImageDelegate implements ZefyrImageDelegate<ImageSource> {
// ...

@override
Widget buildImage(BuildContext context, String imageSource) {
final file = new File.fromUri(Uri.parse(imageSource));
/// Create standard [FileImage] provider. If [imageSource] was an HTTP link
/// we could use [NetworkImage] instead.
final image = new FileImage(file);
return new Image(image: image);
}
}
```

### Previous

* [Heuristics][heuristics]

[heuristics]: /doc/heuristics.md
5 changes: 5 additions & 0 deletions packages/notus/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 0.1.3

- Fixed handling of user input around embeds.
- Added new heuristic rule to preserve block style on paste

## 0.1.2

* Upgraded dependency on quiver_hashcode to 2.0.0.
Expand Down
2 changes: 1 addition & 1 deletion packages/notus/lib/src/document.dart
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ class NotusDocument {

if (_delta != _root.toDelta()) {
throw new StateError('Compose produced inconsistent results. '
'This is likely due to a bug in the library.');
'This is likely due to a bug in the library. Tried to compose change $change from $source.');
}
_controller.add(new NotusChange(before, change, source));
}
Expand Down
1 change: 1 addition & 0 deletions packages/notus/lib/src/heuristics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class NotusHeuristics {
// attributes.
],
insertRules: [
PreserveBlockStyleOnPasteRule(),
ForceNewlineForInsertsAroundEmbedRule(),
PreserveLineStyleOnSplitRule(),
AutoExitBlockRule(),
Expand Down
10 changes: 9 additions & 1 deletion packages/notus/lib/src/heuristics/delete_rules.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,14 @@ class EnsureEmbedLineRule extends DeleteRule {
@override
Delta apply(Delta document, int index, int length) {
DeltaIterator iter = new DeltaIterator(document);

// First, check if line-break deleted after an embed.
Operation op = iter.skip(index);
int indexDelta = 0;
int lengthDelta = 0;
int remaining = length;
bool foundEmbed = false;
bool hasLineBreakBefore = false;
if (op != null && op.data.endsWith(kZeroWidthSpace)) {
foundEmbed = true;
Operation candidate = iter.next(1);
Expand All @@ -102,17 +104,23 @@ class EnsureEmbedLineRule extends DeleteRule {
lengthDelta += 1;
}
}
} else {
// If op is `null` it's a beginning of the doc, e.g. implicit line break.
hasLineBreakBefore = op == null || op.data.endsWith('\n');
}

// Second, check if line-break deleted before an embed.
op = iter.skip(remaining);
if (op != null && op.data.endsWith('\n')) {
final candidate = iter.next(1);
if (candidate.data == kZeroWidthSpace) {
// If there is a line-break before deleted range we allow the operation
// since it results in a correctly formatted line with single embed in it.
if (candidate.data == kZeroWidthSpace && !hasLineBreakBefore) {
foundEmbed = true;
lengthDelta -= 1;
}
}

if (foundEmbed) {
return new Delta()
..retain(index + indexDelta)
Expand Down
69 changes: 68 additions & 1 deletion packages/notus/lib/src/heuristics/insert_rules.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ class PreserveLineStyleOnSplitRule extends InsertRule {
}
}

/// Resets format for a newly inserted line when insert occurred at the end
/// of a line (right before a line-break).

/// Resets format for a newly inserted line when insert occurred at the end
class ResetLineFormatOnNewLineRule extends InsertRule {
const ResetLineFormatOnNewLineRule();

Expand Down Expand Up @@ -256,3 +257,69 @@ class ForceNewlineForInsertsAroundEmbedRule extends InsertRule {
return null;
}
}

/// Preserves block style when user pastes text containing line-breaks.
/// This rule may also be activated for changes triggered by auto-correct.
class PreserveBlockStyleOnPasteRule extends InsertRule {
const PreserveBlockStyleOnPasteRule();

bool isEdgeLineSplit(Operation before, Operation after) {
if (before == null) return true; // split at the beginning of a doc
return before.data.endsWith('\n') || after.data.startsWith('\n');
}

@override
Delta apply(Delta document, int index, String text) {
if (!text.contains('\n') || text.length == 1) {
// Only interested in text containing at least one line-break and at least
// one more character.
return null;
}

DeltaIterator iter = new DeltaIterator(document);
iter.skip(index);

// Look for next line-break.
Map<String, dynamic> lineStyle;
while (iter.hasNext) {
final op = iter.next();
int lf = op.data.indexOf('\n');
if (lf >= 0) {
lineStyle = op.attributes;
break;
}
}

Map<String, dynamic> resetStyle = null;
Map<String, dynamic> blockStyle = null;
if (lineStyle != null) {
if (lineStyle.containsKey(NotusAttribute.heading.key)) {
resetStyle = NotusAttribute.heading.unset.toJson();
}

if (lineStyle.containsKey(NotusAttribute.block.key)) {
blockStyle = <String, dynamic>{
NotusAttribute.block.key: lineStyle[NotusAttribute.block.key]
};
}
}

final lines = text.split('\n');
Delta result = new Delta()..retain(index);
for (int i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.isNotEmpty) {
result.insert(line);
}
if (i == 0) {
result.insert('\n', lineStyle);
} else if (i == lines.length - 1) {
if (resetStyle != null) result.retain(1, resetStyle);
} else {
result.insert('\n', blockStyle);
}
}

return result;
}
}
2 changes: 1 addition & 1 deletion packages/notus/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: notus
description: Platform-agnostic rich text document model based on Delta format and used in Zefyr editor.
version: 0.1.2
version: 0.1.3
author: Anatoly Pulyaevskiy <[email protected]>
homepage: https://github.com/memspace/zefyr

Expand Down
14 changes: 14 additions & 0 deletions packages/notus/test/heuristics/delete_rules_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,5 +110,19 @@ void main() {
..delete(1);
expect(actual, expected);
});

test('allows deleting empty line(s) before embed', () {
final hr = NotusAttribute.embed.horizontalRule;
final doc = new Delta()
..insert('Document\n')
..insert('\n')
..insert('\n')
..insert(kZeroWidthSpace, hr.toJson())
..insert('\n')
..insert('Text')
..insert('\n');
final actual = rule.apply(doc, 11, 1);
expect(actual, isNull);
});
});
}
22 changes: 20 additions & 2 deletions packages/notus/test/heuristics/insert_rules_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@ void main() {
});

test('applies at the beginning of a document', () {
final doc = new Delta()
..insert('\n', NotusAttribute.h1.toJson());
final doc = new Delta()..insert('\n', NotusAttribute.h1.toJson());
final actual = rule.apply(doc, 0, '\n');
expect(actual, isNotNull);
final expected = new Delta()
Expand Down Expand Up @@ -212,4 +211,23 @@ void main() {
expect(actual, isNull);
});
});

group('$PreserveBlockStyleOnPasteRule', () {
final rule = new PreserveBlockStyleOnPasteRule();

test('applies in a block', () {
final doc = new Delta()
..insert('One and two')
..insert('\n', ul)
..insert('Three')
..insert('\n', ul);
final actual = rule.apply(doc, 8, 'also \n');
final expected = new Delta()
..retain(8)
..insert('also ')
..insert('\n', ul);
expect(actual, isNotNull);
expect(actual, expected);
});
});
}
1 change: 1 addition & 0 deletions packages/zefyr/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ ios/Flutter/Generated.xcconfig
example/ios/.symlinks
example/ios/Flutter/Generated.xcconfig
doc/api/
build/
6 changes: 4 additions & 2 deletions packages/zefyr/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
## 0.1.3
## 0.2.0

* Breaking change: `ZefyrImageDelegate.createImageProvider` replaced with
`ZefyrImageDelegate.buildImage`.
* Fixed: Prevent redundant updates on composing range for Android.
* Fixed redundant updates on composing range for Android.
* Added TextCapitalization.sentences
* Added docs for embedding images.

## 0.1.2

Expand Down
Loading