Compare commits

...
12 Commits
Author SHA1 Message Date
Lucas.Xu 8afd6a6f1f feat: implement auto focus 2022-10-30 20:24:05 +08:00
appflowy 0f8eb47602 chore: update version 2022-10-30 19:44:20 +08:00
Nathan.fooo badbbecf0d fix: padding for edit field (#1400) 2022-10-30 19:36:27 +08:00
Nathan.foooandnathan aec205394b feat: optimaze operation size
* Feat/op compose (#1392)

* fix: support float value in attributes (#1396)

Co-authored-by: nathan <nathan@appflowy.io>

* fix: changeset composing (#1398)

Co-authored-by: nathan <nathan@appflowy.io>
2022-10-30 14:39:49 +08:00
Lucas.Xu d24625c053 Merge pull request #1375 from LucasXu0/release/0.0.6.1
Release 0.0.6.1
2022-10-27 14:12:44 +08:00
nathan b293150b19 test: fix test 2022-10-27 07:11:41 +08:00
Lucas.Xu 3bec4caa53 chore: enable CI check for release/* branch 2022-10-26 21:40:26 +08:00
Lucas.Xu d0d7529d28 chore: update changlog and version 2022-10-26 21:36:44 +08:00
appflowy 6884e97146 fix: group by field 2022-10-26 21:28:48 +08:00
Lucas.Xu 9477f00831 fix: selection menu show position 2022-10-26 21:26:52 +08:00
Lucas.Xu e426aa5195 fix: copy link and code style 2022-10-26 20:24:55 +08:00
Lucas.Xu ceb309ac50 feat: adapt dark mode 2022-10-26 20:24:30 +08:00
90 changed files with 2989 additions and 1371 deletions

No files matched your search

@@ -4,10 +4,12 @@ on:
push:
branches:
- "main"
- "release/*"
pull_request:
branches:
- "main"
- "release/*"
paths:
- "frontend/app_flowy/packages/appflowy_editor/**"
+2
View File
@@ -4,10 +4,12 @@ on:
push:
branches:
- "main"
- "release/*"
pull_request:
branches:
- "main"
- "release/*"
jobs:
build:
+2
View File
@@ -9,12 +9,14 @@ on:
push:
branches:
- "main"
- "release/*"
paths:
- "frontend/app_flowy/**"
pull_request:
branches:
- "main"
- "release/*"
paths:
- "frontend/app_flowy/**"
+2
View File
@@ -4,12 +4,14 @@ on:
push:
branches:
- "main"
- "release/*"
paths:
- "frontend/app_flowy/**"
pull_request:
branches:
- "main"
- "release/*"
paths:
- "frontend/app_flowy/**"
+2
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- "main"
- "release/*"
paths:
- "frontend/rust-lib/**"
- "shared-lib/**"
@@ -11,6 +12,7 @@ on:
pull_request:
branches:
- "main"
- "release/*"
paths:
- "frontend/rust-lib/**"
- "shared-lib/**"
+2
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- "main"
- "release/*"
paths:
- "frontend/rust-lib/**"
- "shared-lib/**"
@@ -11,6 +12,7 @@ on:
pull_request:
branches:
- "main"
- "release/*"
paths:
- "frontend/rust-lib/**"
- "shared-lib/**"
+2
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- "main"
- "release/*"
paths:
- "frontend/rust-lib/**"
- "shared-lib/**"
@@ -11,6 +12,7 @@ on:
pull_request:
branches:
- "main"
- "release/*"
paths:
- "frontend/rust-lib/**"
- "shared-lib/**"
+12
View File
@@ -1,5 +1,17 @@
# Release Notes
## Version 0.0.6.2 - 10/30/2022
- Fix some bugs
## Version 0.0.6.1 - 10/26/2022
New features
- Optimzie appflowy_editor dark mode style
### Bug Fixes
- Unable to copy the text with checkbox or link style
## Version 0.0.6 - 10/23/2022
New features
+1 -1
View File
@@ -22,7 +22,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
CURRENT_APP_VERSION = "0.0.6"
CURRENT_APP_VERSION = "0.0.6.2"
FEATURES = "flutter"
PRODUCT_NAME = "AppFlowy"
# CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html
@@ -34,7 +34,8 @@ class BoardDataController {
// key: the block id
final LinkedHashMap<String, GridBlockCache> _blocks;
LinkedHashMap<String, GridBlockCache> get blocks => _blocks;
UnmodifiableMapView<String, GridBlockCache> get blocks =>
UnmodifiableMapView(_blocks);
OnFieldsChanged? _onFieldsChanged;
OnGridChanged? _onGridChanged;
@@ -93,15 +93,21 @@ class _DocumentPageState extends State<DocumentPage> {
}
Widget _renderAppFlowyEditor(EditorState editorState) {
final theme = Theme.of(context);
final editor = AppFlowyEditor(
editorState: editorState,
editorStyle: customEditorStyle(context),
autoFocus: editorState.document.isEmpty,
customBuilders: {
'horizontal_rule': HorizontalRuleWidgetBuilder(),
},
shortcutEvents: [
insertHorizontalRule,
],
themeData: theme.copyWith(extensions: [
...theme.extensions.values,
customEditorTheme(context),
...customPluginTheme(context),
]),
);
return Expanded(
child: SizedBox.expand(
@@ -3,59 +3,63 @@ import 'package:flowy_infra/theme.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
EditorStyle customEditorStyle(BuildContext context) {
const _baseFontSize = 14.0;
EditorStyle customEditorTheme(BuildContext context) {
final theme = context.watch<AppTheme>();
const baseFontSize = 14.0;
const basePadding = 12.0;
var textStyle = theme.isDark
? BuiltInTextStyle.builtInDarkMode()
: BuiltInTextStyle.builtIn();
textStyle = textStyle.copyWith(
defaultTextStyle: textStyle.defaultTextStyle.copyWith(
var editorStyle = theme.isDark ? EditorStyle.dark : EditorStyle.light;
editorStyle = editorStyle.copyWith(
textStyle: editorStyle.textStyle?.copyWith(
fontFamily: 'poppins',
fontSize: baseFontSize,
fontSize: _baseFontSize,
),
bold: textStyle.bold.copyWith(
placeholderTextStyle: editorStyle.placeholderTextStyle?.copyWith(
fontFamily: 'poppins',
fontSize: _baseFontSize,
),
bold: editorStyle.bold?.copyWith(
fontWeight: FontWeight.w500,
),
);
return EditorStyle.defaultStyle().copyWith(
padding: const EdgeInsets.symmetric(horizontal: 80),
textStyle: textStyle,
pluginStyles: {
'text/heading': builtInPluginStyle
..update(
'textStyle',
(_) => (EditorState editorState, Node node) {
final headingToFontSize = {
'h1': baseFontSize + 12,
'h2': baseFontSize + 8,
'h3': baseFontSize + 4,
'h4': baseFontSize,
'h5': baseFontSize,
'h6': baseFontSize,
};
final fontSize =
headingToFontSize[node.attributes.heading] ?? baseFontSize;
return TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600);
},
)
..update(
'padding',
(_) => (EditorState editorState, Node node) {
final headingToPadding = {
'h1': basePadding + 6,
'h2': basePadding + 4,
'h3': basePadding + 2,
'h4': basePadding,
'h5': basePadding,
'h6': basePadding,
};
final padding =
headingToPadding[node.attributes.heading] ?? basePadding;
return EdgeInsets.only(bottom: padding);
},
)
return editorStyle;
}
Iterable<ThemeExtension<dynamic>> customPluginTheme(BuildContext context) {
final theme = context.watch<AppTheme>();
const basePadding = 12.0;
var headingPluginStyle =
theme.isDark ? HeadingPluginStyle.dark : HeadingPluginStyle.light;
headingPluginStyle = headingPluginStyle.copyWith(
textStyle: (EditorState editorState, Node node) {
final headingToFontSize = {
'h1': _baseFontSize + 12,
'h2': _baseFontSize + 8,
'h3': _baseFontSize + 4,
'h4': _baseFontSize,
'h5': _baseFontSize,
'h6': _baseFontSize,
};
final fontSize =
headingToFontSize[node.attributes.heading] ?? _baseFontSize;
return TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600);
},
padding: (EditorState editorState, Node node) {
final headingToPadding = {
'h1': basePadding + 6,
'h2': basePadding + 4,
'h3': basePadding + 2,
'h4': basePadding,
'h5': basePadding,
'h6': basePadding,
};
final padding = headingToPadding[node.attributes.heading] ?? basePadding;
return EdgeInsets.only(bottom: padding);
},
);
final pluginTheme =
theme.isDark ? darkPlguinStyleExtension : lightPlguinStyleExtension;
return pluginTheme.toList()
..removeWhere((element) => element is HeadingPluginStyle)
..add(headingPluginStyle);
}
@@ -38,9 +38,11 @@ ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
name: () => 'Horizontal rule',
icon: const Icon(
icon: (editorState, onSelected) => Icon(
Icons.horizontal_rule,
color: Colors.black,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['horizontal rule'],
@@ -53,20 +53,23 @@ class _SelectOptionCellEditorState extends State<SelectOptionCellEditor> {
)..add(const SelectOptionEditorEvent.initial()),
child: BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
builder: (context, state) {
return CustomScrollView(
shrinkWrap: true,
slivers: [
SliverToBoxAdapter(
child: _TextField(popoverMutex: popoverMutex),
),
const SliverToBoxAdapter(child: VSpace(6)),
const SliverToBoxAdapter(child: TypeOptionSeparator()),
const SliverToBoxAdapter(child: VSpace(6)),
const SliverToBoxAdapter(child: _Title()),
SliverToBoxAdapter(
child: _OptionList(popoverMutex: popoverMutex),
),
],
return Padding(
padding: const EdgeInsets.all(6.0),
child: CustomScrollView(
shrinkWrap: true,
slivers: [
SliverToBoxAdapter(
child: _TextField(popoverMutex: popoverMutex),
),
const SliverToBoxAdapter(child: VSpace(6)),
const SliverToBoxAdapter(child: TypeOptionSeparator()),
const SliverToBoxAdapter(child: VSpace(6)),
const SliverToBoxAdapter(child: _Title()),
SliverToBoxAdapter(
child: _OptionList(popoverMutex: popoverMutex),
),
],
),
);
},
),
@@ -168,7 +168,6 @@ class FieldCellButton extends StatelessWidget {
.replaceAll(Characters(''), Characters('\u{200B}'))
.toString();
return FlowyButton(
radius: BorderRadius.zero,
hoverColor: theme.shader6,
onTap: onTap,
leftIcon: svgWidget(field.fieldType.iconName(), color: theme.iconColor),
@@ -62,10 +62,13 @@ class SelectOptionTypeOptionEditor extends StatelessWidget {
return SizedBox(
width: 160,
child: CustomScrollView(
slivers: slivers,
controller: ScrollController(),
physics: StyledScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(6.0),
child: CustomScrollView(
slivers: slivers,
controller: ScrollController(),
physics: StyledScrollPhysics(),
),
),
);
},
@@ -59,7 +59,7 @@ class _RowDetailPageState extends State<RowDetailPage> {
child: Column(
children: [
SizedBox(
height: 40,
height: 30,
child: Row(
children: const [Spacer(), _CloseButton()],
),
@@ -264,15 +264,14 @@ class _RowDetailCellState extends State<_RowDetailCell> {
behavior: HitTestBehavior.translucent,
onTap: () => cell.beginFocus.notify(),
child: AccessoryHover(
contentPadding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
contentPadding: const EdgeInsets.symmetric(horizontal: 3, vertical: 3),
child: cell,
),
);
return IntrinsicHeight(
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
constraints: const BoxConstraints(minHeight: 30),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
@@ -287,9 +286,7 @@ class _RowDetailCellState extends State<_RowDetailCell> {
child: FieldCellButton(
maxLines: null,
field: widget.cellId.fieldContext.field,
onTap: () {
popover.show();
},
onTap: () => popover.show(),
),
),
),
@@ -10,7 +10,6 @@ import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:path_provider/path_provider.dart';
import 'package:universal_html/html.dart' as html;
@@ -38,7 +37,10 @@ class MyApp extends StatelessWidget {
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
// extensions: [HeadingPluginStyle.light],
),
darkTheme: ThemeData.dark(),
themeMode: ThemeMode.dark,
home: const MyHomePage(title: 'AppFlowyEditor Example'),
);
}
@@ -56,7 +58,6 @@ class _MyHomePageState extends State<MyHomePage> {
int _pageIndex = 0;
EditorState? _editorState;
bool darkMode = false;
EditorStyle _editorStyle = EditorStyle.defaultStyle();
Future<String>? _jsonString;
@override
@@ -125,13 +126,33 @@ class _MyHomePageState extends State<MyHomePage> {
_editorState!.transactionStream.listen((event) {
debugPrint('Transaction: ${event.toJson()}');
});
final themeData = darkMode
? ThemeData.dark().copyWith(extensions: [
HeadingPluginStyle.dark,
CheckboxPluginStyle.dark,
NumberListPluginStyle.dark,
QuotedTextPluginStyle.dark,
BulletedListPluginStyle.dark,
EditorStyle.dark,
])
: ThemeData.light().copyWith(
extensions: [
HeadingPluginStyle.light,
CheckboxPluginStyle.light,
NumberListPluginStyle.light,
QuotedTextPluginStyle.light,
BulletedListPluginStyle.light,
EditorStyle.light,
],
);
return Container(
color: darkMode ? Colors.black : Colors.white,
width: MediaQuery.of(context).size.width,
child: AppFlowyEditor(
editorState: _editorState!,
editorStyle: _editorStyle,
themeData: themeData,
editable: true,
autoFocus: _editorState!.document.isEmpty,
customBuilders: {
'text/code_block': CodeBlockNodeWidgetBuilder(),
'tex': TeXBlockNodeWidgetBuidler(),
@@ -186,8 +207,6 @@ class _MyHomePageState extends State<MyHomePage> {
icon: const Icon(Icons.color_lens),
onPressed: () {
setState(() {
_editorStyle =
darkMode ? EditorStyle.defaultStyle() : _customizedStyle();
darkMode = !darkMode;
});
},
@@ -256,44 +275,4 @@ class _MyHomePageState extends State<MyHomePage> {
});
}
}
EditorStyle _customizedStyle() {
final editorStyle = EditorStyle.defaultStyle();
return editorStyle.copyWith(
cursorColor: Colors.white,
selectionColor: Colors.blue.withOpacity(0.3),
textStyle: editorStyle.textStyle.copyWith(
defaultTextStyle: GoogleFonts.poppins().copyWith(
color: Colors.white,
fontSize: 14.0,
),
defaultPlaceholderTextStyle: GoogleFonts.poppins().copyWith(
color: Colors.white.withOpacity(0.5),
fontSize: 14.0,
),
bold: const TextStyle(fontWeight: FontWeight.w900),
code: TextStyle(
fontStyle: FontStyle.italic,
color: Colors.red[300],
backgroundColor: Colors.grey.withOpacity(0.3),
),
highlightColorHex: '0x6FFFEB3B',
),
pluginStyles: {
'text/quote': builtInPluginStyle
..update(
'textStyle',
(_) {
return (EditorState editorState, Node node) {
return TextStyle(
color: Colors.blue[200],
fontStyle: FontStyle.italic,
fontSize: 12.0,
);
};
},
),
},
);
}
}
@@ -46,7 +46,7 @@ ShortcutEventHandler _ignorekHandler = (editorState, event) {
SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
name: () => 'Code Block',
icon: const Icon(
icon: (_, __) => const Icon(
Icons.abc,
color: Colors.black,
size: 18.0,
@@ -167,7 +167,7 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
textNode: widget.textNode,
editorState: widget.editorState,
textSpanDecorator: (textSpan) => TextSpan(
style: widget.editorState.editorStyle.textStyle.defaultTextStyle,
style: widget.editorState.editorStyle.textStyle,
children: codeTextSpan,
),
),
@@ -38,7 +38,7 @@ ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
name: () => 'Horizontal rule',
icon: const Icon(
icon: (_, __) => const Icon(
Icons.horizontal_rule,
color: Colors.black,
size: 18.0,
@@ -6,7 +6,7 @@ import 'package:flutter_math_fork/flutter_math.dart';
SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
name: () => 'Tex',
icon: const Icon(
icon: (_, __) => const Icon(
Icons.text_fields_rounded,
color: Colors.black,
size: 18.0,
@@ -31,3 +31,5 @@ export 'src/render/rich_text/default_selectable.dart';
export 'src/render/rich_text/flowy_rich_text.dart';
export 'src/render/selection_menu/selection_menu_widget.dart';
export 'src/l10n/l10n.dart';
export 'src/render/style/plugin_styles.dart';
export 'src/render/style/editor_style.dart';
@@ -110,6 +110,20 @@ class Document {
return true;
}
bool get isEmpty {
if (root.children.isEmpty) {
return true;
}
final node = root.children.first;
if (node is TextNode &&
(node.delta.isEmpty || node.delta.toPlainText().isEmpty)) {
return true;
}
return false;
}
Map<String, Object> toJson() {
return {
'document': root.toJson(),
@@ -59,13 +59,14 @@ class EditorState {
/// Stores the selection menu items.
List<SelectionMenuItem> selectionMenuItems = [];
/// Stores the editor style.
EditorStyle editorStyle = EditorStyle.defaultStyle();
/// Operation stream.
Stream<Transaction> get transactionStream => _observer.stream;
final StreamController<Transaction> _observer = StreamController.broadcast();
late ThemeData themeData;
EditorStyle get editorStyle =>
themeData.extension<EditorStyle>() ?? EditorStyle.light;
final UndoManager undoManager = UndoManager();
Selection? _cursorSelection;
@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
extension ThemeExtension on ThemeData {
T? extensionOrNull<T>() {
if (extensions.containsKey(T)) {
return extensions[T] as T;
}
return null;
}
}
@@ -241,6 +241,8 @@ class HTMLToNodesConverter {
} else if (element.localName == HTMLTag.del) {
delta.insert(element.text,
attributes: {BuiltInAttributeKey.strikethrough: true});
} else if (element.localName == HTMLTag.code) {
delta.insert(element.text, attributes: {BuiltInAttributeKey.code: true});
} else {
delta.insert(element.text);
}
@@ -276,11 +278,13 @@ class HTMLToNodesConverter {
}
}
final textNode = TextNode(delta: delta, attributes: attributes);
if (isCheckbox) {
textNode.attributes["subtype"] = BuiltInAttributeKey.checkbox;
textNode.attributes["checkbox"] = checked;
}
final textNode = TextNode(delta: delta, attributes: {
if (attributes != null) ...attributes,
if (isCheckbox) ...{
BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
BuiltInAttributeKey.checkbox: checked,
}
});
return textNode;
}
@@ -557,6 +561,17 @@ class NodesToHTMLConverter {
final strong = html.Element.tag(HTMLTag.del);
strong.append(html.Text(op.text));
childNodes.add(strong);
} else if (attributes.length == 1 &&
attributes[BuiltInAttributeKey.code] == true) {
final code = html.Element.tag(HTMLTag.code);
code.append(html.Text(op.text));
childNodes.add(code);
} else if (attributes.length == 1 &&
attributes[BuiltInAttributeKey.href] != null) {
final anchor = html.Element.tag(HTMLTag.anchor);
anchor.attributes["href"] = attributes[BuiltInAttributeKey.href];
anchor.append(html.Text(op.text));
childNodes.add(anchor);
} else {
final span = html.Element.tag(HTMLTag.span);
final cssString = _attributesToCssStyle(attributes);
@@ -2,6 +2,7 @@ import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:appflowy_editor/src/render/style/editor_style.dart';
import 'package:flutter/material.dart';
OverlayEntry? _imageUploadMenu;
@@ -20,6 +21,7 @@ void showImageUploadMenu(
left: menuService.topLeft.dx,
child: Material(
child: ImageUploadMenu(
editorState: editorState,
onSubmitted: (text) {
// _dismissImageUploadMenu();
editorState.insertImageNode(text);
@@ -53,10 +55,12 @@ class ImageUploadMenu extends StatefulWidget {
Key? key,
required this.onSubmitted,
required this.onUpload,
this.editorState,
}) : super(key: key);
final void Function(String text) onSubmitted;
final void Function(String text) onUpload;
final EditorState? editorState;
@override
State<ImageUploadMenu> createState() => _ImageUploadMenuState();
@@ -66,6 +70,8 @@ class _ImageUploadMenuState extends State<ImageUploadMenu> {
final _textEditingController = TextEditingController();
final _focusNode = FocusNode();
EditorStyle? get style => widget.editorState?.editorStyle;
@override
void initState() {
super.initState();
@@ -84,7 +90,7 @@ class _ImageUploadMenuState extends State<ImageUploadMenu> {
width: 300,
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Colors.white,
color: style?.selectionMenuBackgroundColor ?? Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 5,
@@ -92,7 +98,7 @@ class _ImageUploadMenuState extends State<ImageUploadMenu> {
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
// borderRadius: BorderRadius.circular(6.0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -108,12 +114,12 @@ class _ImageUploadMenuState extends State<ImageUploadMenu> {
}
Widget _buildHeader(BuildContext context) {
return const Text(
return Text(
'URL Image',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 14.0,
color: Colors.black,
color: style?.selectionMenuItemTextColor ?? Colors.black,
fontWeight: FontWeight.w500,
),
);
@@ -1,10 +1,13 @@
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/style/editor_style.dart';
import 'package:flutter/material.dart';
class LinkMenu extends StatefulWidget {
const LinkMenu({
Key? key,
this.linkText,
this.editorState,
required this.onSubmitted,
required this.onOpenLink,
required this.onCopyLink,
@@ -13,6 +16,7 @@ class LinkMenu extends StatefulWidget {
}) : super(key: key);
final String? linkText;
final EditorState? editorState;
final void Function(String text) onSubmitted;
final VoidCallback onOpenLink;
final VoidCallback onCopyLink;
@@ -27,6 +31,8 @@ class _LinkMenuState extends State<LinkMenu> {
final _textEditingController = TextEditingController();
final _focusNode = FocusNode();
EditorStyle? get style => widget.editorState?.editorStyle;
@override
void initState() {
super.initState();
@@ -48,7 +54,7 @@ class _LinkMenuState extends State<LinkMenu> {
width: 350,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
color: style?.selectionMenuBackgroundColor ?? Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 5,
@@ -71,17 +77,19 @@ class _LinkMenuState extends State<LinkMenu> {
if (widget.linkText != null) ...[
_buildIconButton(
iconName: 'link',
color: style?.selectionMenuItemIconColor,
text: 'Open link',
onPressed: widget.onOpenLink,
),
_buildIconButton(
iconName: 'copy',
color: Colors.black,
color: style?.selectionMenuItemIconColor,
text: 'Copy link',
onPressed: widget.onCopyLink,
),
_buildIconButton(
iconName: 'delete',
color: style?.selectionMenuItemIconColor,
text: 'Remove link',
onPressed: widget.onRemoveLink,
),
@@ -154,8 +162,8 @@ class _LinkMenuState extends State<LinkMenu> {
label: Text(
text,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.black,
style: TextStyle(
color: style?.selectionMenuItemTextColor ?? Colors.black,
fontSize: 14.0,
),
),
@@ -10,56 +10,6 @@ abstract class BuiltInTextWidget extends StatefulWidget {
TextNode get textNode;
}
mixin BuiltInStyleMixin<T extends BuiltInTextWidget> on State<T> {
EdgeInsets get padding {
final padding = widget.editorState.editorStyle.style(
widget.editorState,
widget.textNode,
'padding',
);
if (padding is EdgeInsets) {
return padding;
}
return const EdgeInsets.all(0);
}
TextStyle get textStyle {
final textStyle = widget.editorState.editorStyle.style(
widget.editorState,
widget.textNode,
'textStyle',
);
if (textStyle is TextStyle) {
return textStyle;
}
return const TextStyle();
}
Size? get iconSize {
final iconSize = widget.editorState.editorStyle.style(
widget.editorState,
widget.textNode,
'iconSize',
);
if (iconSize is Size) {
return iconSize;
}
return const Size.square(18.0);
}
EdgeInsets? get iconPadding {
final iconPadding = widget.editorState.editorStyle.style(
widget.editorState,
widget.textNode,
'iconPadding',
);
if (iconPadding is EdgeInsets) {
return iconPadding;
}
return const EdgeInsets.all(0);
}
}
mixin BuiltInTextWidgetMixin<T extends BuiltInTextWidget> on State<T>
implements DefaultSelectable {
@override
@@ -1,13 +1,14 @@
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/render/style/plugin_styles.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
import 'package:appflowy_editor/src/extensions/theme_extension.dart';
class BulletedListTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
@override
@@ -45,11 +46,7 @@ class BulletedListTextNodeWidget extends BuiltInTextWidget {
// customize
class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin {
@override
final iconKey = GlobalKey();
@@ -64,6 +61,25 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
return super.baseOffset.translate(0, padding.top);
}
BulletedListPluginStyle get style =>
Theme.of(context).extensionOrNull<BulletedListPluginStyle>() ??
BulletedListPluginStyle.light;
EdgeInsets get padding => style.padding(
widget.editorState,
widget.textNode,
);
TextStyle get textStyle => style.textStyle(
widget.editorState,
widget.textNode,
);
Widget get icon => style.icon(
widget.editorState,
widget.textNode,
);
@override
Widget buildWithSingle(BuildContext context) {
return Padding(
@@ -71,12 +87,9 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySvg(
Container(
key: iconKey,
width: iconSize?.width,
height: iconSize?.height,
padding: iconPadding,
name: 'point',
child: icon,
),
Flexible(
child: FlowyRichText(
@@ -86,7 +99,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
textSpan.updateTextStyle(textStyle),
placeholderTextSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle),
lineHeight: widget.editorState.editorStyle.textStyle.lineHeight,
lineHeight: widget.editorState.editorStyle.lineHeight,
textNode: widget.textNode,
editorState: widget.editorState,
),
@@ -1,10 +1,10 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/commands/text/text_commands.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/extensions/theme_extension.dart';
class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
@override
@@ -39,11 +39,7 @@ class CheckboxNodeWidget extends BuiltInTextWidget {
}
class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin {
@override
final iconKey = GlobalKey();
@@ -58,6 +54,25 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
return super.baseOffset.translate(0, padding.top);
}
CheckboxPluginStyle get style =>
Theme.of(context).extensionOrNull<CheckboxPluginStyle>() ??
CheckboxPluginStyle.light;
EdgeInsets get padding => style.padding(
widget.editorState,
widget.textNode,
);
TextStyle get textStyle => style.textStyle(
widget.editorState,
widget.textNode,
);
Widget get icon => style.icon(
widget.editorState,
widget.textNode,
);
@override
Widget buildWithSingle(BuildContext context) {
final check = widget.textNode.attributes.check;
@@ -68,12 +83,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
children: [
GestureDetector(
key: iconKey,
child: FlowySvg(
width: iconSize?.width,
height: iconSize?.height,
padding: iconPadding,
name: check ? 'check' : 'uncheck',
),
child: icon,
onTap: () async {
await widget.editorState.formatTextToCheckbox(
widget.editorState,
@@ -86,7 +96,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'To-do',
lineHeight: widget.editorState.editorStyle.textStyle.lineHeight,
lineHeight: widget.editorState.editorStyle.lineHeight,
textNode: widget.textNode,
textSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle),
@@ -202,12 +202,13 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
}
TextSpan get _placeholderTextSpan {
final style = widget.editorState.editorStyle.textStyle;
final placeholderTextStyle =
widget.editorState.editorStyle.placeholderTextStyle;
return TextSpan(
children: [
TextSpan(
text: widget.placeholderText,
style: style.defaultPlaceholderTextStyle,
style: placeholderTextStyle,
),
],
);
@@ -216,10 +217,10 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
TextSpan get _textSpan {
var offset = 0;
List<TextSpan> textSpans = [];
final style = widget.editorState.editorStyle.textStyle;
final style = widget.editorState.editorStyle;
final textInserts = widget.textNode.delta.whereType<TextInsert>();
for (final textInsert in textInserts) {
var textStyle = style.defaultTextStyle;
var textStyle = style.textStyle!;
GestureRecognizer? recognizer;
final attributes = textInsert.attributes;
if (attributes != null) {
@@ -4,10 +4,12 @@ import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/render/style/plugin_styles.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
import 'package:appflowy_editor/src/extensions/theme_extension.dart';
class HeadingTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
@override
@@ -43,7 +45,7 @@ class HeadingTextNodeWidget extends BuiltInTextWidget {
// customize
class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
with SelectableMixin, DefaultSelectable {
@override
GlobalKey? get iconKey => null;
@@ -58,6 +60,20 @@ class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
return padding.topLeft;
}
HeadingPluginStyle get style =>
Theme.of(context).extensionOrNull<HeadingPluginStyle>() ??
HeadingPluginStyle.light;
EdgeInsets get padding => style.padding(
widget.editorState,
widget.textNode,
);
TextStyle get textStyle => style.textStyle(
widget.editorState,
widget.textNode,
);
@override
Widget build(BuildContext context) {
return Padding(
@@ -68,7 +84,7 @@ class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
placeholderTextSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle),
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(textStyle),
lineHeight: widget.editorState.editorStyle.textStyle.lineHeight,
lineHeight: widget.editorState.editorStyle.lineHeight,
textNode: widget.textNode,
editorState: widget.editorState,
),
@@ -4,10 +4,12 @@ import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/render/style/plugin_styles.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
import 'package:appflowy_editor/src/extensions/theme_extension.dart';
class NumberListTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
@override
@@ -43,7 +45,7 @@ class NumberListTextNodeWidget extends BuiltInTextWidget {
}
class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
with SelectableMixin, DefaultSelectable {
@override
final iconKey = GlobalKey();
@@ -58,6 +60,25 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
return super.baseOffset.translate(0, padding.top);
}
NumberListPluginStyle get style =>
Theme.of(context).extensionOrNull<NumberListPluginStyle>() ??
NumberListPluginStyle.light;
EdgeInsets get padding => style.padding(
widget.editorState,
widget.textNode,
);
TextStyle get textStyle => style.textStyle(
widget.editorState,
widget.textNode,
);
Widget get icon => style.icon(
widget.editorState,
widget.textNode,
);
@override
Widget build(BuildContext context) {
return Padding(
@@ -67,12 +88,7 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
children: [
Container(
key: iconKey,
padding: iconPadding,
child: Text(
'${widget.textNode.attributes.number.toString()}.',
// FIXME: customize
style: const TextStyle(fontSize: 16.0, color: Colors.black),
),
child: icon,
),
Flexible(
child: FlowyRichText(
@@ -80,7 +96,7 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
placeholderText: 'List',
textNode: widget.textNode,
editorState: widget.editorState,
lineHeight: widget.editorState.editorStyle.textStyle.lineHeight,
lineHeight: widget.editorState.editorStyle.lineHeight,
placeholderTextSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle),
textSpanDecorator: (textSpan) =>
@@ -1,13 +1,14 @@
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/render/style/plugin_styles.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
import 'package:appflowy_editor/src/extensions/theme_extension.dart';
class QuotedTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
@override
@@ -44,7 +45,7 @@ class QuotedTextNodeWidget extends BuiltInTextWidget {
// customize
class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
with SelectableMixin, DefaultSelectable {
@override
final iconKey = GlobalKey();
@@ -59,6 +60,25 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
return super.baseOffset.translate(0, padding.top);
}
QuotedTextPluginStyle get style =>
Theme.of(context).extensionOrNull<QuotedTextPluginStyle>() ??
QuotedTextPluginStyle.light;
EdgeInsets get padding => style.padding(
widget.editorState,
widget.textNode,
);
TextStyle get textStyle => style.textStyle(
widget.editorState,
widget.textNode,
);
Widget get icon => style.icon(
widget.editorState,
widget.textNode,
);
@override
Widget build(BuildContext context) {
return Padding(
@@ -67,11 +87,9 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FlowySvg(
Container(
key: iconKey,
width: iconSize?.width,
padding: iconPadding,
name: 'quote',
child: icon,
),
Flexible(
child: FlowyRichText(
@@ -82,7 +100,7 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
textSpan.updateTextStyle(textStyle),
placeholderTextSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle),
lineHeight: widget.editorState.editorStyle.textStyle.lineHeight,
lineHeight: widget.editorState.editorStyle.lineHeight,
editorState: widget.editorState,
),
),
@@ -4,6 +4,7 @@ import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/render/style/editor_style.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
@@ -43,11 +44,7 @@ class RichTextNodeWidget extends BuiltInTextWidget {
// customize
class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin {
@override
GlobalKey? get iconKey => null;
@@ -59,20 +56,26 @@ class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
@override
Offset get baseOffset {
return padding.topLeft;
return textPadding.topLeft;
}
EditorStyle get style => widget.editorState.editorStyle;
EdgeInsets get textPadding => style.textPadding!;
TextStyle get textStyle => style.textStyle!;
@override
Widget buildWithSingle(BuildContext context) {
return Padding(
padding: padding,
padding: textPadding,
child: FlowyRichText(
key: _richTextKey,
textNode: widget.textNode,
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(textStyle),
placeholderTextSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle),
lineHeight: widget.editorState.editorStyle.textStyle.lineHeight,
lineHeight: widget.editorState.editorStyle.lineHeight,
editorState: widget.editorState,
),
);
@@ -3,7 +3,7 @@ import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:flutter/material.dart';
class SelectionMenuItemWidget extends StatelessWidget {
class SelectionMenuItemWidget extends StatefulWidget {
const SelectionMenuItemWidget({
Key? key,
required this.editorState,
@@ -11,7 +11,6 @@ class SelectionMenuItemWidget extends StatelessWidget {
required this.item,
required this.isSelected,
this.width = 140.0,
this.selectedColor = const Color(0xFFE0F8FF),
}) : super(key: key);
final EditorState editorState;
@@ -19,33 +18,52 @@ class SelectionMenuItemWidget extends StatelessWidget {
final SelectionMenuItem item;
final double width;
final bool isSelected;
final Color selectedColor;
@override
State<SelectionMenuItemWidget> createState() =>
_SelectionMenuItemWidgetState();
}
class _SelectionMenuItemWidgetState extends State<SelectionMenuItemWidget> {
var _onHover = false;
@override
Widget build(BuildContext context) {
final editorStyle = widget.editorState.editorStyle;
return Container(
padding: const EdgeInsets.fromLTRB(8.0, 5.0, 8.0, 5.0),
child: SizedBox(
width: width,
width: widget.width,
child: TextButton.icon(
icon: item.icon,
icon: widget.item
.icon(widget.editorState, widget.isSelected || _onHover),
style: ButtonStyle(
alignment: Alignment.centerLeft,
overlayColor: MaterialStateProperty.all(selectedColor),
backgroundColor: isSelected
? MaterialStateProperty.all(selectedColor)
overlayColor: MaterialStateProperty.all(
editorStyle.selectionMenuItemSelectedColor),
backgroundColor: widget.isSelected
? MaterialStateProperty.all(
editorStyle.selectionMenuItemSelectedColor)
: MaterialStateProperty.all(Colors.transparent),
),
label: Text(
item.name(),
widget.item.name(),
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.black,
fontSize: 14.0,
style: TextStyle(
color: (widget.isSelected || _onHover)
? editorStyle.selectionMenuItemSelectedTextColor
: editorStyle.selectionMenuItemTextColor,
fontSize: 12.0,
),
),
onPressed: () {
item.handler(editorState, menuService, context);
widget.item
.handler(widget.editorState, widget.menuService, context);
},
onHover: (value) {
setState(() {
_onHover = value;
});
},
),
),
@@ -60,20 +60,29 @@ class SelectionMenu implements SelectionMenuService {
// but the coordinates of overlay are not properly converted currently.
// Just subtract the padding here as a result.
const menuHeight = 200.0;
const menuOffset = Offset(10, 10);
final baseOffset =
const menuOffset = Offset(0, 10);
final editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
var offset = selectionRects.first.bottomRight + menuOffset;
if (offset.dy >=
baseOffset.dy + editorState.renderBox!.size.height - menuHeight) {
offset = selectionRects.first.topRight - menuOffset;
offset = offset.translate(0, -menuHeight);
final editorHeight = editorState.renderBox!.size.height;
// show below defualt
var showBelow = true;
final bottomRight = selectionRects.first.bottomRight;
final topRight = selectionRects.first.topRight;
var offset = bottomRight + menuOffset;
// overflow
if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) {
// show above
offset = topRight - menuOffset;
showBelow = false;
}
_topLeft = offset;
_selectionMenuEntry = OverlayEntry(builder: (context) {
return Positioned(
top: offset.dy,
top: showBelow ? offset.dy : null,
bottom:
showBelow ? null : MediaQuery.of(context).size.height - offset.dy,
left: offset.dx,
child: SelectionMenuWidget(
items: [
@@ -131,7 +140,8 @@ List<SelectionMenuItem> get defaultSelectionMenuItems =>
final List<SelectionMenuItem> _defaultSelectionMenuItems = [
SelectionMenuItem(
name: () => AppFlowyEditorLocalizations.current.text,
icon: _selectionMenuIcon('text'),
icon: (editorState, onSelected) =>
_selectionMenuIcon('text', editorState, onSelected),
keywords: ['text'],
handler: (editorState, _, __) {
insertTextNodeAfterSelection(editorState, {});
@@ -139,7 +149,8 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
),
SelectionMenuItem(
name: () => AppFlowyEditorLocalizations.current.heading1,
icon: _selectionMenuIcon('h1'),
icon: (editorState, onSelected) =>
_selectionMenuIcon('h1', editorState, onSelected),
keywords: ['heading 1, h1'],
handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, BuiltInAttributeKey.h1);
@@ -147,7 +158,8 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
),
SelectionMenuItem(
name: () => AppFlowyEditorLocalizations.current.heading2,
icon: _selectionMenuIcon('h2'),
icon: (editorState, onSelected) =>
_selectionMenuIcon('h2', editorState, onSelected),
keywords: ['heading 2, h2'],
handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, BuiltInAttributeKey.h2);
@@ -155,7 +167,8 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
),
SelectionMenuItem(
name: () => AppFlowyEditorLocalizations.current.heading3,
icon: _selectionMenuIcon('h3'),
icon: (editorState, onSelected) =>
_selectionMenuIcon('h3', editorState, onSelected),
keywords: ['heading 3, h3'],
handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, BuiltInAttributeKey.h3);
@@ -163,13 +176,15 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
),
SelectionMenuItem(
name: () => AppFlowyEditorLocalizations.current.image,
icon: _selectionMenuIcon('image'),
icon: (editorState, onSelected) =>
_selectionMenuIcon('image', editorState, onSelected),
keywords: ['image'],
handler: showImageUploadMenu,
),
SelectionMenuItem(
name: () => AppFlowyEditorLocalizations.current.bulletedList,
icon: _selectionMenuIcon('bulleted_list'),
icon: (editorState, onSelected) =>
_selectionMenuIcon('bulleted_list', editorState, onSelected),
keywords: ['bulleted list', 'list', 'unordered list'],
handler: (editorState, _, __) {
insertBulletedListAfterSelection(editorState);
@@ -177,7 +192,8 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
),
SelectionMenuItem(
name: () => AppFlowyEditorLocalizations.current.numberedList,
icon: _selectionMenuIcon('number'),
icon: (editorState, onSelected) =>
_selectionMenuIcon('number', editorState, onSelected),
keywords: ['numbered list', 'list', 'ordered list'],
handler: (editorState, _, __) {
insertNumberedListAfterSelection(editorState);
@@ -185,7 +201,8 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
),
SelectionMenuItem(
name: () => AppFlowyEditorLocalizations.current.checkbox,
icon: _selectionMenuIcon('checkbox'),
icon: (editorState, onSelected) =>
_selectionMenuIcon('checkbox', editorState, onSelected),
keywords: ['todo list', 'list', 'checkbox list'],
handler: (editorState, _, __) {
insertCheckboxAfterSelection(editorState);
@@ -193,7 +210,8 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
),
SelectionMenuItem(
name: () => AppFlowyEditorLocalizations.current.quote,
icon: _selectionMenuIcon('quote'),
icon: (editorState, onSelected) =>
_selectionMenuIcon('quote', editorState, onSelected),
keywords: ['quote', 'refer'],
handler: (editorState, _, __) {
insertQuoteAfterSelection(editorState);
@@ -201,10 +219,13 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
),
];
Widget _selectionMenuIcon(String name) {
Widget _selectionMenuIcon(
String name, EditorState editorState, bool onSelected) {
return FlowySvg(
name: 'selection_menu/$name',
color: Colors.black,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
width: 18.0,
height: 18.0,
);
@@ -29,7 +29,7 @@ class SelectionMenuItem {
}
final String Function() name;
final Widget icon;
final Widget Function(EditorState editorState, bool onSelected) icon;
/// Customizes keywords for item.
///
@@ -142,7 +142,7 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
onKey: _onKey,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
color: widget.editorState.editorStyle.selectionMenuBackgroundColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
@@ -1,202 +1,78 @@
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
Iterable<ThemeExtension<dynamic>> get lightEditorStyleExtension => [
EditorStyle.light,
];
typedef PluginStyler = Object Function(EditorState editorState, Node node);
typedef PluginStyle = Map<String, PluginStyler>;
Iterable<ThemeExtension<dynamic>> get darkEditorStyleExtension => [
EditorStyle.dark,
];
class EditorStyle extends ThemeExtension<EditorStyle> {
// Editor styles
final EdgeInsets? padding;
final Color? cursorColor;
final Color? selectionColor;
// Selection menu styles
final Color? selectionMenuBackgroundColor;
final Color? selectionMenuItemTextColor;
final Color? selectionMenuItemIconColor;
final Color? selectionMenuItemSelectedTextColor;
final Color? selectionMenuItemSelectedIconColor;
final Color? selectionMenuItemSelectedColor;
// Text styles
final EdgeInsets? textPadding;
final TextStyle? textStyle;
final TextStyle? placeholderTextStyle;
final double lineHeight;
// Rich text styles
final TextStyle? bold;
final TextStyle? italic;
final TextStyle? underline;
final TextStyle? strikethrough;
final TextStyle? href;
final TextStyle? code;
final String? highlightColorHex;
/// Editor style configuration
class EditorStyle {
EditorStyle({
required this.padding,
required this.textStyle,
required this.cursorColor,
required this.selectionColor,
Map<String, PluginStyle> pluginStyles = const {},
}) {
_pluginStyles.addAll(pluginStyles);
}
EditorStyle.defaultStyle()
: padding = const EdgeInsets.fromLTRB(200.0, 0.0, 200.0, 0.0),
textStyle = BuiltInTextStyle.builtIn(),
cursorColor = const Color(0xFF00BCF0),
selectionColor = const Color.fromARGB(53, 111, 201, 231);
/// The margin of the document context from the editor.
final EdgeInsets padding;
final BuiltInTextStyle textStyle;
final Color cursorColor;
final Color selectionColor;
final Map<String, PluginStyle> _pluginStyles = Map.from(builtInTextStylers);
Object? style(EditorState editorState, Node node, String key) {
final styler = _pluginStyles[node.id]?[key];
if (styler != null) {
return styler(editorState, node);
}
return null;
}
EditorStyle copyWith({
EdgeInsets? padding,
BuiltInTextStyle? textStyle,
Color? cursorColor,
Color? selectionColor,
Map<String, PluginStyle>? pluginStyles,
}) {
return EditorStyle(
padding: padding ?? this.padding,
textStyle: textStyle ?? this.textStyle,
cursorColor: cursorColor ?? this.cursorColor,
selectionColor: selectionColor ?? this.selectionColor,
pluginStyles: pluginStyles ?? {},
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is EditorStyle &&
other.padding == padding &&
other.textStyle == textStyle &&
other.cursorColor == cursorColor &&
other.selectionColor == selectionColor;
}
@override
int get hashCode {
return padding.hashCode ^
textStyle.hashCode ^
cursorColor.hashCode ^
selectionColor.hashCode;
}
}
PluginStyle get builtInPluginStyle => Map.from({
'padding': (_, __) => const EdgeInsets.symmetric(vertical: 8.0),
'textStyle': (_, __) => const TextStyle(),
'iconSize': (_, __) => const Size.square(20.0),
'iconPadding': (_, __) => const EdgeInsets.only(right: 5.0),
});
Map<String, PluginStyle> builtInTextStylers = {
'text': builtInPluginStyle,
'text/checkbox': builtInPluginStyle
..update(
'textStyle',
(_) => (EditorState editorState, Node node) {
if (node is TextNode && node.attributes.check == true) {
return const TextStyle(
color: Colors.grey,
decoration: TextDecoration.lineThrough,
);
}
return const TextStyle();
},
),
'text/heading': builtInPluginStyle
..update(
'textStyle',
(_) => (EditorState editorState, Node node) {
final headingToFontSize = {
'h1': 32.0,
'h2': 28.0,
'h3': 24.0,
'h4': 18.0,
'h5': 18.0,
'h6': 18.0,
};
final fontSize = headingToFontSize[node.attributes.heading] ?? 18.0;
return TextStyle(fontSize: fontSize, fontWeight: FontWeight.bold);
},
),
'text/bulleted-list': builtInPluginStyle,
'text/number-list': builtInPluginStyle
..update(
'iconPadding',
(_) => (EditorState editorState, Node node) {
return const EdgeInsets.only(left: 5.0, right: 5.0);
},
),
'text/quote': builtInPluginStyle,
'image': builtInPluginStyle,
};
class BuiltInTextStyle {
const BuiltInTextStyle({
required this.defaultTextStyle,
required this.defaultPlaceholderTextStyle,
required this.selectionMenuBackgroundColor,
required this.selectionMenuItemTextColor,
required this.selectionMenuItemIconColor,
required this.selectionMenuItemSelectedTextColor,
required this.selectionMenuItemSelectedIconColor,
required this.selectionMenuItemSelectedColor,
required this.textPadding,
required this.textStyle,
required this.placeholderTextStyle,
required this.bold,
required this.italic,
required this.underline,
required this.strikethrough,
required this.href,
required this.code,
this.highlightColorHex = '0x6000BCF0',
this.lineHeight = 1.5,
required this.highlightColorHex,
required this.lineHeight,
});
final TextStyle defaultTextStyle;
final TextStyle defaultPlaceholderTextStyle;
final TextStyle bold;
final TextStyle italic;
final TextStyle underline;
final TextStyle strikethrough;
final TextStyle href;
final TextStyle code;
final String highlightColorHex;
final double lineHeight;
BuiltInTextStyle.builtIn()
: defaultTextStyle = const TextStyle(fontSize: 16.0, color: Colors.black),
defaultPlaceholderTextStyle =
const TextStyle(fontSize: 16.0, color: Colors.grey),
bold = const TextStyle(fontWeight: FontWeight.bold),
italic = const TextStyle(fontStyle: FontStyle.italic),
underline = const TextStyle(decoration: TextDecoration.underline),
strikethrough = const TextStyle(decoration: TextDecoration.lineThrough),
href = const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
code = const TextStyle(
fontFamily: 'monospace',
color: Color(0xFF00BCF0),
backgroundColor: Color(0xFFE0F8FF),
),
highlightColorHex = '0x6000BCF0',
lineHeight = 1.5;
BuiltInTextStyle.builtInDarkMode()
: defaultTextStyle = const TextStyle(fontSize: 16.0, color: Colors.white),
defaultPlaceholderTextStyle = TextStyle(
fontSize: 16.0,
color: Colors.white.withOpacity(0.3),
),
bold = const TextStyle(fontWeight: FontWeight.bold),
italic = const TextStyle(fontStyle: FontStyle.italic),
underline = const TextStyle(decoration: TextDecoration.underline),
strikethrough = const TextStyle(decoration: TextDecoration.lineThrough),
href = const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
code = const TextStyle(
fontFamily: 'monospace',
color: Color(0xFF00BCF0),
backgroundColor: Color(0xFFE0F8FF),
),
highlightColorHex = '0x6000BCF0',
lineHeight = 1.5;
BuiltInTextStyle copyWith({
TextStyle? defaultTextStyle,
TextStyle? defaultPlaceholderTextStyle,
@override
EditorStyle copyWith({
EdgeInsets? padding,
Color? cursorColor,
Color? selectionColor,
Color? selectionMenuBackgroundColor,
Color? selectionMenuItemTextColor,
Color? selectionMenuItemIconColor,
Color? selectionMenuItemSelectedTextColor,
Color? selectionMenuItemSelectedIconColor,
Color? selectionMenuItemSelectedColor,
TextStyle? textStyle,
TextStyle? placeholderTextStyle,
TextStyle? bold,
TextStyle? italic,
TextStyle? underline,
@@ -206,10 +82,25 @@ class BuiltInTextStyle {
String? highlightColorHex,
double? lineHeight,
}) {
return BuiltInTextStyle(
defaultTextStyle: defaultTextStyle ?? this.defaultTextStyle,
defaultPlaceholderTextStyle:
defaultPlaceholderTextStyle ?? this.defaultPlaceholderTextStyle,
return EditorStyle(
padding: padding ?? this.padding,
cursorColor: cursorColor ?? this.cursorColor,
selectionColor: selectionColor ?? this.selectionColor,
selectionMenuBackgroundColor:
selectionMenuBackgroundColor ?? this.selectionMenuBackgroundColor,
selectionMenuItemTextColor:
selectionMenuItemTextColor ?? this.selectionMenuItemTextColor,
selectionMenuItemIconColor:
selectionMenuItemIconColor ?? this.selectionMenuItemIconColor,
selectionMenuItemSelectedTextColor: selectionMenuItemSelectedTextColor ??
this.selectionMenuItemSelectedTextColor,
selectionMenuItemSelectedIconColor: selectionMenuItemSelectedIconColor ??
this.selectionMenuItemSelectedIconColor,
selectionMenuItemSelectedColor:
selectionMenuItemSelectedColor ?? this.selectionMenuItemSelectedColor,
textPadding: textPadding ?? textPadding,
textStyle: textStyle ?? this.textStyle,
placeholderTextStyle: placeholderTextStyle ?? this.placeholderTextStyle,
bold: bold ?? this.bold,
italic: italic ?? this.italic,
underline: underline ?? this.underline,
@@ -222,33 +113,87 @@ class BuiltInTextStyle {
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BuiltInTextStyle &&
other.defaultTextStyle == defaultTextStyle &&
other.defaultPlaceholderTextStyle == defaultPlaceholderTextStyle &&
other.bold == bold &&
other.italic == italic &&
other.underline == underline &&
other.strikethrough == strikethrough &&
other.href == href &&
other.code == code &&
other.highlightColorHex == highlightColorHex &&
other.lineHeight == lineHeight;
ThemeExtension<EditorStyle> lerp(
ThemeExtension<EditorStyle>? other, double t) {
if (other == null || other is! EditorStyle) {
return this;
}
return EditorStyle(
padding: EdgeInsets.lerp(padding, other.padding, t),
cursorColor: Color.lerp(cursorColor, other.cursorColor, t),
textPadding: EdgeInsets.lerp(textPadding, other.textPadding, t),
selectionColor: Color.lerp(selectionColor, other.selectionColor, t),
selectionMenuBackgroundColor: Color.lerp(
selectionMenuBackgroundColor, other.selectionMenuBackgroundColor, t),
selectionMenuItemTextColor: Color.lerp(
selectionMenuItemTextColor, other.selectionMenuItemTextColor, t),
selectionMenuItemIconColor: Color.lerp(
selectionMenuItemIconColor, other.selectionMenuItemIconColor, t),
selectionMenuItemSelectedTextColor: Color.lerp(
selectionMenuItemSelectedTextColor,
other.selectionMenuItemSelectedTextColor,
t),
selectionMenuItemSelectedIconColor: Color.lerp(
selectionMenuItemSelectedIconColor,
other.selectionMenuItemSelectedIconColor,
t),
selectionMenuItemSelectedColor: Color.lerp(selectionMenuItemSelectedColor,
other.selectionMenuItemSelectedColor, t),
textStyle: TextStyle.lerp(textStyle, other.textStyle, t),
placeholderTextStyle:
TextStyle.lerp(placeholderTextStyle, other.placeholderTextStyle, t),
bold: TextStyle.lerp(bold, other.bold, t),
italic: TextStyle.lerp(italic, other.italic, t),
underline: TextStyle.lerp(underline, other.underline, t),
strikethrough: TextStyle.lerp(strikethrough, other.strikethrough, t),
href: TextStyle.lerp(href, other.href, t),
code: TextStyle.lerp(code, other.code, t),
highlightColorHex: highlightColorHex,
lineHeight: lineHeight,
);
}
@override
int get hashCode {
return defaultTextStyle.hashCode ^
defaultPlaceholderTextStyle.hashCode ^
bold.hashCode ^
italic.hashCode ^
underline.hashCode ^
strikethrough.hashCode ^
href.hashCode ^
code.hashCode ^
highlightColorHex.hashCode ^
lineHeight.hashCode;
}
static final light = EditorStyle(
padding: const EdgeInsets.fromLTRB(200.0, 0.0, 200.0, 0.0),
cursorColor: const Color(0xFF00BCF0),
selectionColor: const Color.fromARGB(53, 111, 201, 231),
selectionMenuBackgroundColor: const Color(0xFFFFFFFF),
selectionMenuItemTextColor: const Color(0xFF333333),
selectionMenuItemIconColor: const Color(0xFF333333),
selectionMenuItemSelectedTextColor: const Color(0xFF333333),
selectionMenuItemSelectedIconColor: const Color(0xFF333333),
selectionMenuItemSelectedColor: const Color(0xFFE0F8FF),
textPadding: const EdgeInsets.symmetric(vertical: 8.0),
textStyle: const TextStyle(fontSize: 16.0, color: Colors.black),
placeholderTextStyle: const TextStyle(fontSize: 16.0, color: Colors.grey),
bold: const TextStyle(fontWeight: FontWeight.bold),
italic: const TextStyle(fontStyle: FontStyle.italic),
underline: const TextStyle(decoration: TextDecoration.underline),
strikethrough: const TextStyle(decoration: TextDecoration.lineThrough),
href: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
code: const TextStyle(
fontFamily: 'monospace',
color: Color(0xFF00BCF0),
backgroundColor: Color(0xFFE0F8FF),
),
highlightColorHex: '0x6000BCF0',
lineHeight: 1.5,
);
static final dark = light.copyWith(
textStyle: const TextStyle(fontSize: 16.0, color: Colors.white),
placeholderTextStyle: TextStyle(
fontSize: 16.0,
color: Colors.white.withOpacity(0.3),
),
selectionMenuBackgroundColor: const Color(0xFF282E3A),
selectionMenuItemTextColor: const Color(0xFFBBC3CD),
selectionMenuItemIconColor: const Color(0xFFBBC3CD),
selectionMenuItemSelectedTextColor: const Color(0xFF131720),
selectionMenuItemSelectedIconColor: const Color(0xFF131720),
selectionMenuItemSelectedColor: const Color(0xFF00BCF0),
);
}
@@ -0,0 +1,327 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:flutter/material.dart';
Iterable<ThemeExtension<dynamic>> get lightPlguinStyleExtension => [
HeadingPluginStyle.light,
CheckboxPluginStyle.light,
NumberListPluginStyle.light,
QuotedTextPluginStyle.light,
];
Iterable<ThemeExtension<dynamic>> get darkPlguinStyleExtension => [
HeadingPluginStyle.dark,
CheckboxPluginStyle.dark,
NumberListPluginStyle.dark,
QuotedTextPluginStyle.dark,
BulletedListPluginStyle.dark,
];
typedef TextStyleCustomizer = TextStyle Function(
EditorState editorState, TextNode textNode);
typedef PaddingCustomizer = EdgeInsets Function(
EditorState editorState, TextNode textNode);
typedef IconCustomizer = Widget Function(
EditorState editorState, TextNode textNode);
class HeadingPluginStyle extends ThemeExtension<HeadingPluginStyle> {
const HeadingPluginStyle({
required this.textStyle,
required this.padding,
});
final TextStyleCustomizer textStyle;
final PaddingCustomizer padding;
@override
HeadingPluginStyle copyWith({
TextStyleCustomizer? textStyle,
PaddingCustomizer? padding,
}) {
return HeadingPluginStyle(
textStyle: textStyle ?? this.textStyle,
padding: padding ?? this.padding,
);
}
@override
ThemeExtension<HeadingPluginStyle> lerp(
ThemeExtension<HeadingPluginStyle>? other, double t) {
if (other is! HeadingPluginStyle) {
return this;
}
return HeadingPluginStyle(
textStyle: other.textStyle,
padding: other.padding,
);
}
static final light = HeadingPluginStyle(
padding: (_, __) => const EdgeInsets.symmetric(vertical: 8.0),
textStyle: (editorState, textNode) {
final headingToFontSize = {
'h1': 32.0,
'h2': 28.0,
'h3': 24.0,
'h4': 18.0,
'h5': 18.0,
'h6': 18.0,
};
final fontSize = headingToFontSize[textNode.attributes.heading] ?? 18.0;
return TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
);
},
);
static final dark = light;
}
class CheckboxPluginStyle extends ThemeExtension<CheckboxPluginStyle> {
const CheckboxPluginStyle({
required this.textStyle,
required this.padding,
required this.icon,
});
final TextStyleCustomizer textStyle;
final PaddingCustomizer padding;
final IconCustomizer icon;
@override
CheckboxPluginStyle copyWith({
TextStyleCustomizer? textStyle,
PaddingCustomizer? padding,
IconCustomizer? icon,
}) {
return CheckboxPluginStyle(
textStyle: textStyle ?? this.textStyle,
padding: padding ?? this.padding,
icon: icon ?? this.icon,
);
}
@override
ThemeExtension<CheckboxPluginStyle> lerp(
ThemeExtension<CheckboxPluginStyle>? other, double t) {
if (other is! CheckboxPluginStyle) {
return this;
}
return CheckboxPluginStyle(
textStyle: other.textStyle,
padding: other.padding,
icon: other.icon,
);
}
static final light = CheckboxPluginStyle(
padding: (_, __) => const EdgeInsets.symmetric(vertical: 8.0),
textStyle: (editorState, textNode) => const TextStyle(),
icon: (editorState, textNode) {
final isCheck = textNode.attributes.check;
const iconSize = Size.square(20.0);
const iconPadding = EdgeInsets.only(right: 5.0);
return FlowySvg(
width: iconSize.width,
height: iconSize.height,
padding: iconPadding,
name: isCheck ? 'check' : 'uncheck',
);
},
);
static final dark = light;
}
class BulletedListPluginStyle extends ThemeExtension<BulletedListPluginStyle> {
const BulletedListPluginStyle({
required this.textStyle,
required this.padding,
required this.icon,
});
final TextStyleCustomizer textStyle;
final PaddingCustomizer padding;
final IconCustomizer icon;
@override
BulletedListPluginStyle copyWith({
TextStyleCustomizer? textStyle,
PaddingCustomizer? padding,
IconCustomizer? icon,
}) {
return BulletedListPluginStyle(
textStyle: textStyle ?? this.textStyle,
padding: padding ?? this.padding,
icon: icon ?? this.icon,
);
}
@override
ThemeExtension<BulletedListPluginStyle> lerp(
ThemeExtension<BulletedListPluginStyle>? other, double t) {
if (other is! BulletedListPluginStyle) {
return this;
}
return BulletedListPluginStyle(
textStyle: other.textStyle,
padding: other.padding,
icon: other.icon,
);
}
static final light = BulletedListPluginStyle(
padding: (_, __) => const EdgeInsets.symmetric(vertical: 8.0),
textStyle: (_, __) => const TextStyle(),
icon: (_, __) {
const iconSize = Size.square(20.0);
const iconPadding = EdgeInsets.only(right: 5.0);
return FlowySvg(
width: iconSize.width,
height: iconSize.height,
padding: iconPadding,
color: Colors.black,
name: 'point',
);
},
);
static final dark = light.copyWith(icon: (_, __) {
const iconSize = Size.square(20.0);
const iconPadding = EdgeInsets.only(right: 5.0);
return FlowySvg(
width: iconSize.width,
height: iconSize.height,
padding: iconPadding,
color: Colors.white,
name: 'point',
);
});
}
class NumberListPluginStyle extends ThemeExtension<NumberListPluginStyle> {
const NumberListPluginStyle({
required this.textStyle,
required this.padding,
required this.icon,
});
final TextStyleCustomizer textStyle;
final PaddingCustomizer padding;
final IconCustomizer icon;
@override
NumberListPluginStyle copyWith({
TextStyleCustomizer? textStyle,
PaddingCustomizer? padding,
IconCustomizer? icon,
}) {
return NumberListPluginStyle(
textStyle: textStyle ?? this.textStyle,
padding: padding ?? this.padding,
icon: icon ?? this.icon,
);
}
@override
ThemeExtension<NumberListPluginStyle> lerp(
ThemeExtension<NumberListPluginStyle>? other,
double t,
) {
if (other is! NumberListPluginStyle) {
return this;
}
return NumberListPluginStyle(
textStyle: other.textStyle,
padding: other.padding,
icon: other.icon,
);
}
static final light = NumberListPluginStyle(
padding: (_, __) => const EdgeInsets.symmetric(vertical: 8.0),
textStyle: (_, __) => const TextStyle(),
icon: (_, textNode) {
const iconPadding = EdgeInsets.only(left: 5.0, right: 5.0);
return Container(
padding: iconPadding,
child: Text(
'${textNode.attributes.number.toString()}.',
style: const TextStyle(
fontSize: 16,
color: Colors.black,
),
),
);
},
);
static final dark = light.copyWith(icon: (editorState, textNode) {
const iconPadding = EdgeInsets.only(left: 5.0, right: 5.0);
return Container(
padding: iconPadding,
child: Text(
'${textNode.attributes.number.toString()}.',
style: const TextStyle(
fontSize: 16,
color: Colors.white,
),
),
);
});
}
class QuotedTextPluginStyle extends ThemeExtension<QuotedTextPluginStyle> {
const QuotedTextPluginStyle({
required this.textStyle,
required this.padding,
required this.icon,
});
final TextStyleCustomizer textStyle;
final PaddingCustomizer padding;
final IconCustomizer icon;
@override
QuotedTextPluginStyle copyWith({
TextStyleCustomizer? textStyle,
PaddingCustomizer? padding,
IconCustomizer? icon,
}) {
return QuotedTextPluginStyle(
textStyle: textStyle ?? this.textStyle,
padding: padding ?? this.padding,
icon: icon ?? this.icon,
);
}
@override
ThemeExtension<QuotedTextPluginStyle> lerp(
ThemeExtension<QuotedTextPluginStyle>? other, double t) {
if (other is! QuotedTextPluginStyle) {
return this;
}
return QuotedTextPluginStyle(
textStyle: other.textStyle,
padding: other.padding,
icon: other.icon,
);
}
static final light = QuotedTextPluginStyle(
padding: (_, __) => const EdgeInsets.symmetric(vertical: 8.0),
textStyle: (_, __) => const TextStyle(),
icon: (_, __) {
const iconSize = Size.square(20.0);
const iconPadding = EdgeInsets.only(right: 5.0);
return FlowySvg(
width: iconSize.width,
padding: iconPadding,
name: 'quote',
);
},
);
static final dark = light;
}
@@ -259,7 +259,7 @@ List<ToolbarItem> defaultToolbarItems = [
),
handler: (editorState, context) => formatHighlight(
editorState,
editorState.editorStyle.textStyle.highlightColorHex,
editorState.editorStyle.highlightColorHex!,
),
),
];
@@ -348,6 +348,7 @@ void showLinkMenu(
child: Material(
child: LinkMenu(
linkText: linkText,
editorState: editorState,
onOpenLink: () async {
await safeLaunchUrl(linkText);
},
@@ -25,6 +25,7 @@ class ToolbarItemWidget extends StatelessWidget {
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: IconButton(
hoverColor: Colors.transparent,
highlightColor: Colors.transparent,
padding: EdgeInsets.zero,
icon: item.iconBuilder(isHighlight),
@@ -30,26 +30,43 @@ class ContextMenu extends StatelessWidget {
final children = <Widget>[];
for (var i = 0; i < items.length; i++) {
for (var j = 0; j < items[i].length; j++) {
var onHover = false;
children.add(
Material(
child: InkWell(
hoverColor: const Color(0xFFE0F8FF),
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
onTap: () {
items[i][j].onPressed(editorState);
onPressed();
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
items[i][j].name,
textAlign: TextAlign.start,
style: const TextStyle(fontSize: 14),
StatefulBuilder(
builder: (BuildContext context, setState) {
return Material(
color: editorState.editorStyle.selectionMenuBackgroundColor,
child: InkWell(
hoverColor:
editorState.editorStyle.selectionMenuItemSelectedColor,
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
onTap: () {
items[i][j].onPressed(editorState);
onPressed();
},
onHover: (value) => setState(() {
onHover = value;
}),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
items[i][j].name,
textAlign: TextAlign.start,
style: TextStyle(
fontSize: 14,
color: onHover
? editorState
.editorStyle.selectionMenuItemSelectedTextColor
: editorState
.editorStyle.selectionMenuItemTextColor,
),
),
),
),
),
),
);
},
),
);
}
@@ -67,7 +84,7 @@ class ContextMenu extends StatelessWidget {
minWidth: 140,
),
decoration: BoxDecoration(
color: Colors.white,
color: editorState.editorStyle.selectionMenuBackgroundColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
@@ -1,12 +1,9 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/flutter/overlay.dart';
import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:appflowy_editor/src/render/style/editor_style.dart';
import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/render/editor/editor_entry.dart';
import 'package:appflowy_editor/src/render/rich_text/bulleted_list_text.dart';
import 'package:appflowy_editor/src/render/rich_text/checkbox_text.dart';
@@ -14,12 +11,6 @@ import 'package:appflowy_editor/src/render/rich_text/heading_text.dart';
import 'package:appflowy_editor/src/render/rich_text/number_list_text.dart';
import 'package:appflowy_editor/src/render/rich_text/quoted_text.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text.dart';
import 'package:appflowy_editor/src/service/input_service.dart';
import 'package:appflowy_editor/src/service/keyboard_service.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:appflowy_editor/src/service/scroll_service.dart';
import 'package:appflowy_editor/src/service/selection_service.dart';
import 'package:appflowy_editor/src/service/toolbar_service.dart';
NodeWidgetBuilders defaultBuilders = {
'editor': EditorEntryWidgetBuilder(),
@@ -33,15 +24,22 @@ NodeWidgetBuilders defaultBuilders = {
};
class AppFlowyEditor extends StatefulWidget {
const AppFlowyEditor({
AppFlowyEditor({
Key? key,
required this.editorState,
this.customBuilders = const {},
this.shortcutEvents = const [],
this.selectionMenuItems = const [],
this.editable = true,
required this.editorStyle,
}) : super(key: key);
this.autoFocus = false,
ThemeData? themeData,
}) : super(key: key) {
this.themeData = themeData ??
ThemeData.light().copyWith(extensions: [
...lightEditorStyleExtension,
...lightPlguinStyleExtension,
]);
}
final EditorState editorState;
@@ -53,10 +51,13 @@ class AppFlowyEditor extends StatefulWidget {
final List<SelectionMenuItem> selectionMenuItems;
final EditorStyle editorStyle;
late final ThemeData themeData;
final bool editable;
/// Set the value to true to focus the editor on the start of the document.
final bool autoFocus;
@override
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
}
@@ -65,15 +66,26 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
Widget? services;
EditorState get editorState => widget.editorState;
EditorStyle get editorStyle =>
editorState.themeData.extension<EditorStyle>() ?? EditorStyle.light;
@override
void initState() {
super.initState();
editorState.selectionMenuItems = widget.selectionMenuItems;
editorState.editorStyle = widget.editorStyle;
editorState.themeData = widget.themeData;
editorState.service.renderPluginService = _createRenderPlugin();
editorState.editable = widget.editable;
// auto focus
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (widget.editable && widget.autoFocus) {
editorState.service.selectionService.updateSelection(
Selection.single(path: [0], startOffset: 0),
);
}
});
}
@override
@@ -85,7 +97,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
editorState.service.renderPluginService = _createRenderPlugin();
}
editorState.editorStyle = widget.editorStyle;
editorState.themeData = widget.themeData;
editorState.editable = widget.editable;
services = null;
}
@@ -102,38 +114,41 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
);
}
AppFlowyScroll _buildServices(BuildContext context) {
return AppFlowyScroll(
key: editorState.service.scrollServiceKey,
child: Padding(
padding: widget.editorStyle.padding,
child: AppFlowySelection(
key: editorState.service.selectionServiceKey,
cursorColor: widget.editorStyle.cursorColor,
selectionColor: widget.editorStyle.selectionColor,
editorState: editorState,
editable: widget.editable,
child: AppFlowyInput(
key: editorState.service.inputServiceKey,
Widget _buildServices(BuildContext context) {
return Theme(
data: widget.themeData,
child: AppFlowyScroll(
key: editorState.service.scrollServiceKey,
child: Padding(
padding: editorStyle.padding!,
child: AppFlowySelection(
key: editorState.service.selectionServiceKey,
cursorColor: editorStyle.cursorColor!,
selectionColor: editorStyle.selectionColor!,
editorState: editorState,
editable: widget.editable,
child: AppFlowyKeyboard(
key: editorState.service.keyboardServiceKey,
editable: widget.editable,
shortcutEvents: [
...widget.shortcutEvents,
...builtInShortcutEvents,
],
child: AppFlowyInput(
key: editorState.service.inputServiceKey,
editorState: editorState,
child: FlowyToolbar(
key: editorState.service.toolbarServiceKey,
editable: widget.editable,
child: AppFlowyKeyboard(
key: editorState.service.keyboardServiceKey,
editable: widget.editable,
shortcutEvents: [
...widget.shortcutEvents,
...builtInShortcutEvents,
],
editorState: editorState,
child:
editorState.service.renderPluginService.buildPluginWidget(
NodeWidgetContext(
context: context,
node: editorState.document.root,
editorState: editorState,
child: FlowyToolbar(
key: editorState.service.toolbarServiceKey,
editorState: editorState,
child:
editorState.service.renderPluginService.buildPluginWidget(
NodeWidgetContext(
context: context,
node: editorState.document.root,
editorState: editorState,
),
),
),
),
@@ -41,7 +41,10 @@ void _handleCopy(EditorState editorState) async {
Log.keyboard.debug('copy html: $htmlString');
RichClipboard.setData(RichClipboardData(
html: htmlString,
text: textNode.toPlainText(),
text: textNode.toPlainText().substring(
selection.startIndex,
selection.endIndex,
),
));
} else {
Log.keyboard.debug('unimplemented: copy non-text');
@@ -63,9 +66,19 @@ void _handleCopy(EditorState editorState) async {
startOffset: selection.start.offset,
endOffset: selection.end.offset,
).toHTMLString();
final text = nodes
.map((node) => node is TextNode ? node.toPlainText() : '\n')
.join('\n');
var text = '';
for (final node in nodes) {
if (node is TextNode) {
if (node.path == selection.start.path) {
text += node.toPlainText().substring(selection.start.offset);
} else if (node.path == selection.end.path) {
text += node.toPlainText().substring(0, selection.end.offset);
} else {
text += node.toPlainText();
}
}
text += '\n';
}
RichClipboard.setData(RichClipboardData(html: html, text: text));
}
@@ -57,7 +57,7 @@ ShortcutEventHandler formatHighlightEventHandler = (editorState, event) {
}
formatHighlight(
editorState,
editorState.editorStyle.textStyle.highlightColorHex,
editorState.editorStyle.highlightColorHex!,
);
return KeyEventResult.handled;
};
@@ -73,5 +73,66 @@ void main() async {
final document = Document.fromJson(json);
expect(document.toJson(), json);
});
test('isEmpty', () {
expect(
true,
Document.fromJson({
'document': {
'type': 'editor',
'children': [
{
'type': 'text',
'delta': [],
}
],
}
}).isEmpty,
);
expect(
true,
Document.fromJson({
'document': {
'type': 'editor',
'children': [],
}
}).isEmpty,
);
expect(
true,
Document.fromJson({
'document': {
'type': 'editor',
'children': [
{
'type': 'text',
'delta': [
{'insert': ''}
],
}
],
}
}).isEmpty,
);
expect(
false,
Document.fromJson({
'document': {
'type': 'editor',
'children': [
{
'type': 'text',
'delta': [
{'insert': 'Welcome to AppFlowy!'}
],
}
],
}
}).isEmpty,
);
});
});
}
@@ -39,7 +39,6 @@ class EditorWidgetTester {
home: Scaffold(
body: AppFlowyEditor(
editorState: _editorState,
editorStyle: EditorStyle.defaultStyle(),
),
),
);
@@ -49,10 +49,11 @@ void main() async {
final editorRect = tester.getRect(editorFinder);
final leftImageRect = tester.getRect(imageFinder.at(0));
expect(leftImageRect.left, editor.editorState.editorStyle.padding.left);
expect(
leftImageRect.left, editor.editorState.editorStyle.padding!.left);
final rightImageRect = tester.getRect(imageFinder.at(2));
expect(rightImageRect.right,
editorRect.right - editor.editorState.editorStyle.padding.right);
editorRect.right - editor.editorState.editorStyle.padding!.right);
final centerImageRect = tester.getRect(imageFinder.at(1));
expect(centerImageRect.left,
(leftImageRect.left + rightImageRect.left) / 2.0);
@@ -137,7 +137,7 @@ Future<EditorWidgetTester> _prepare(WidgetTester tester) async {
);
for (final item in defaultSelectionMenuItems) {
expect(find.byWidget(item.icon), findsOneWidget);
expect(find.text(item.name()), findsOneWidget);
}
return Future.value(editor);
@@ -30,7 +30,7 @@ void main() async {
);
for (final item in defaultSelectionMenuItems) {
expect(find.byWidget(item.icon), findsOneWidget);
expect(find.text(item.name()), findsOneWidget);
}
await editor.updateSelection(Selection.single(path: [1], startOffset: 0));
@@ -29,7 +29,8 @@ class FlowyButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InkWell(
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: FlowyHover(
style: HoverStyle(
+1 -1
View File
@@ -35,7 +35,7 @@ packages:
path: "packages/appflowy_editor"
relative: true
source: path
version: "0.0.6"
version: "0.0.7"
appflowy_popover:
dependency: "direct main"
description:
@@ -1,8 +1,12 @@
import 'dart:collection';
import 'package:app_flowy/plugins/board/application/board_data_controller.dart';
import 'package:app_flowy/plugins/board/board.dart';
import 'package:app_flowy/plugins/grid/application/block/block_cache.dart';
import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
import 'package:app_flowy/plugins/grid/application/field/field_controller.dart';
import 'package:app_flowy/plugins/grid/application/field/field_editor_bloc.dart';
import 'package:app_flowy/plugins/grid/application/field/field_service.dart';
import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart';
import 'package:app_flowy/plugins/grid/application/grid_data_controller.dart';
import 'package:app_flowy/plugins/grid/application/row/row_bloc.dart';
import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
@@ -16,29 +20,90 @@ import '../../util.dart';
/// Create a empty Grid for test
class AppFlowyGridTest {
final AppFlowyUnitTest _inner;
final AppFlowyUnitTest unitTest;
late ViewPB gridView;
late GridDataController _dataController;
GridDataController? _gridDataController;
BoardDataController? _boardDataController;
AppFlowyGridTest(AppFlowyUnitTest unitTest) : _inner = unitTest;
AppFlowyGridTest({required this.unitTest});
static Future<AppFlowyGridTest> ensureInitialized() async {
final inner = await AppFlowyUnitTest.ensureInitialized();
return AppFlowyGridTest(inner);
return AppFlowyGridTest(unitTest: inner);
}
List<RowInfo> get rowInfos => _dataController.rowInfos;
List<RowInfo> get rowInfos {
if (_gridDataController != null) {
return _gridDataController!.rowInfos;
}
UnmodifiableMapView<String, GridBlockCache> get blocks =>
_dataController.blocks;
if (_boardDataController != null) {
return _boardDataController!.rowInfos;
}
List<GridFieldContext> get fieldContexts =>
_dataController.fieldController.fieldContexts;
throw Exception();
}
GridFieldController get fieldController => _dataController.fieldController;
UnmodifiableMapView<String, GridBlockCache> get blocks {
if (_gridDataController != null) {
return _gridDataController!.blocks;
}
if (_boardDataController != null) {
return _boardDataController!.blocks;
}
throw Exception();
}
List<GridFieldContext> get fieldContexts => fieldController.fieldContexts;
GridFieldController get fieldController {
if (_gridDataController != null) {
return _gridDataController!.fieldController;
}
if (_boardDataController != null) {
return _boardDataController!.fieldController;
}
throw Exception();
}
Future<void> createRow() async {
await _dataController.createRow();
if (_gridDataController != null) {
return _gridDataController!.createRow();
}
throw Exception();
}
FieldEditorBloc createFieldEditor({
GridFieldContext? fieldContext,
}) {
IFieldTypeOptionLoader loader;
if (fieldContext == null) {
loader = NewFieldTypeOptionLoader(gridId: gridView.id);
} else {
loader =
FieldTypeOptionLoader(gridId: gridView.id, field: fieldContext.field);
}
final editorBloc = FieldEditorBloc(
fieldName: fieldContext?.name ?? '',
isGroupField: fieldContext?.isGroupField ?? false,
loader: loader,
gridId: gridView.id,
);
return editorBloc;
}
Future<FieldEditorBloc> createFieldFromType(FieldType fieldType) async {
final editor = createFieldEditor()..add(const FieldEditorEvent.initial());
await gridResponseFuture();
editor.dataController.switchToField(fieldType);
await gridResponseFuture();
return Future(() => editor);
}
GridFieldContext singleSelectFieldContext() {
@@ -53,7 +118,7 @@ class AppFlowyGridTest {
}
Future<void> createTestGrid() async {
final app = await _inner.createTestApp();
final app = await unitTest.createTestApp();
final builder = GridPluginBuilder();
final result = await AppService().createView(
appId: app.id,
@@ -65,13 +130,34 @@ class AppFlowyGridTest {
await result.fold(
(view) async {
gridView = view;
_dataController = GridDataController(view: view);
final result = await _dataController.loadData();
_gridDataController = GridDataController(view: view);
final result = await _gridDataController!.loadData();
result.fold((l) => null, (r) => throw Exception(r));
},
(error) {},
);
}
Future<void> createTestBoard() async {
final app = await unitTest.createTestApp();
final builder = BoardPluginBuilder();
final result = await AppService().createView(
appId: app.id,
name: "Test Board",
dataFormatType: builder.dataFormatType,
pluginType: builder.pluginType,
layoutType: builder.layoutType!,
);
await result.fold(
(view) async {
_boardDataController = BoardDataController(view: view);
final result = await _boardDataController!.loadData();
result.fold((l) => null, (r) => throw Exception(r));
gridView = view;
},
(error) {},
);
}
}
/// Create a new Grid for cell test
@@ -101,7 +187,7 @@ class AppFlowyGridCellTest {
final rowDataController = GridRowDataController(
rowInfo: rowInfo,
fieldController: _gridTest._dataController.fieldController,
fieldController: _gridTest._gridDataController!.fieldController,
rowCache: rowCache!,
);
@@ -90,6 +90,7 @@ void main() {
"delete the document",
build: () => AppBloc(app: app)..add(const AppEvent.initial()),
act: (bloc) => bloc.add(AppEvent.deleteView(view.id)),
wait: blocResponseDuration(),
verify: (bloc) {
assert(bloc.state.views.isEmpty);
},
@@ -2,9 +2,7 @@ use bytes::Bytes;
use flowy_error::{FlowyError, FlowyResult};
use flowy_revision::{RevisionCompress, RevisionObjectDeserializer, RevisionObjectSerializer};
use flowy_sync::entities::revision::Revision;
use lib_ot::core::{
Body, Extension, NodeDataBuilder, NodeOperation, NodeTree, NodeTreeContext, Selection, Transaction,
};
use lib_ot::core::{Extension, NodeDataBuilder, NodeOperation, NodeTree, NodeTreeContext, Selection, Transaction};
use lib_ot::text_delta::DeltaTextOperationBuilder;
#[derive(Debug)]
@@ -46,7 +44,7 @@ pub(crate) fn make_tree_context() -> NodeTreeContext {
pub fn initial_document_content() -> String {
let delta = DeltaTextOperationBuilder::new().insert("").build();
let node_data = NodeDataBuilder::new("text").insert_body(Body::Delta(delta)).build();
let node_data = NodeDataBuilder::new("text").insert_delta(delta).build();
let editor_node = NodeDataBuilder::new("editor").add_node_data(node_data).build();
let node_operation = NodeOperation::Insert {
path: vec![0].into(),
@@ -117,7 +117,8 @@ impl DocumentTransaction {
impl std::convert::From<Transaction> for DocumentTransaction {
fn from(transaction: Transaction) -> Self {
let (before_selection, after_selection) = match transaction.extension {
let (operations, extension) = transaction.split();
let (before_selection, after_selection) = match extension {
Extension::Empty => (Selection::default(), Selection::default()),
Extension::TextSelection {
before_selection,
@@ -126,9 +127,7 @@ impl std::convert::From<Transaction> for DocumentTransaction {
};
DocumentTransaction {
operations: transaction
.operations
.into_inner()
operations: operations
.into_iter()
.map(|operation| operation.as_ref().into())
.collect(),
@@ -139,19 +138,16 @@ impl std::convert::From<Transaction> for DocumentTransaction {
}
impl std::convert::From<DocumentTransaction> for Transaction {
fn from(transaction: DocumentTransaction) -> Self {
Transaction {
operations: transaction
.operations
.into_iter()
.map(|operation| operation.into())
.collect::<Vec<NodeOperation>>()
.into(),
extension: Extension::TextSelection {
before_selection: transaction.before_selection,
after_selection: transaction.after_selection,
},
fn from(document_transaction: DocumentTransaction) -> Self {
let mut transaction = Transaction::new();
for document_operation in document_transaction.operations {
transaction.push_operation(document_operation);
}
transaction.extension = Extension::TextSelection {
before_selection: document_transaction.before_selection,
after_selection: document_transaction.after_selection,
};
transaction
}
}
@@ -374,6 +370,17 @@ mod tests {
let _ = serde_json::to_string_pretty(&document).unwrap();
}
// #[test]
// fn document_operation_compose_test() {
// let json = include_str!("./test.json");
// let transaction: Transaction = Transaction::from_json(json).unwrap();
// let json = transaction.to_json().unwrap();
// // let transaction: Transaction = Transaction::from_json(&json).unwrap();
// let document = Document::from_transaction(transaction).unwrap();
// let content = document.get_content(false).unwrap();
// println!("{}", json);
// }
const EXAMPLE_DOCUMENT: &str = r#"{
"document": {
"type": "editor",
@@ -36,8 +36,7 @@ fn attributes_insert_text_at_middle() {
#[test]
fn delta_get_ops_in_interval_1() {
let operations = OperationsBuilder::new().insert("123").insert("4").build();
let delta = DeltaTextOperationBuilder::from_operations(operations);
let delta = DeltaTextOperationBuilder::new().insert("123").insert("4").build();
let mut iterator = OperationIterator::from_interval(&delta, Interval::new(0, 4));
assert_eq!(iterator.ops(), delta.ops);
@@ -89,7 +89,7 @@ fn delta_deserialize_null_test() {
let delta1 = DeltaTextOperations::from_json(json).unwrap();
let mut attribute = BuildInTextAttribute::Bold(true);
attribute.remove_value();
attribute.clear();
let delta2 = DeltaOperationBuilder::new()
.retain_with_attributes(7, attribute.into())
@@ -3,7 +3,7 @@ use flowy_document::editor::{AppFlowyDocumentEditor, Document, DocumentTransacti
use flowy_document::entities::DocumentVersionPB;
use flowy_test::helper::ViewTest;
use flowy_test::FlowySDKTest;
use lib_ot::core::{Body, Changeset, NodeDataBuilder, NodeOperation, Path, Transaction};
use lib_ot::core::{Changeset, NodeDataBuilder, NodeOperation, Path, Transaction};
use lib_ot::text_delta::DeltaTextOperations;
use std::sync::Arc;
@@ -64,7 +64,7 @@ impl DocumentEditorTest {
async fn run_script(&self, script: EditScript) {
match script {
EditScript::InsertText { path, delta } => {
let node_data = NodeDataBuilder::new("text").insert_body(Body::Delta(delta)).build();
let node_data = NodeDataBuilder::new("text").insert_delta(delta).build();
let operation = NodeOperation::Insert {
path,
nodes: vec![node_data],
@@ -119,7 +119,6 @@ fn read_workspaces_on_server(
let workspace_revs = server.read_workspace(&token, params).await?;
let _ = persistence
.begin_transaction(|transaction| {
tracing::trace!("Save {} workspace", workspace_revs.len());
for workspace_rev in &workspace_revs {
let m_workspace = workspace_rev.clone();
let app_revs = m_workspace.apps.clone();
@@ -465,19 +465,6 @@ pub struct FieldChangesetParams {
pub type_option_data: Option<Vec<u8>>,
}
impl FieldChangesetParams {
pub fn has_changes(&self) -> bool {
self.name.is_some()
|| self.desc.is_some()
|| self.field_type.is_some()
|| self.frozen.is_some()
|| self.type_option_data.is_some()
|| self.frozen.is_some()
|| self.visibility.is_some()
|| self.width.is_some()
}
}
/// Certain field types have user-defined options such as color, date format, number format,
/// or a list of values for a multi-select list. These options are defined within a specialization
/// of the FieldTypeOption class.
@@ -109,10 +109,6 @@ impl GridRevisionEditor {
field_id: &str,
type_option_data: Vec<u8>,
) -> FlowyResult<()> {
if type_option_data.is_empty() {
return Ok(());
}
let result = self.get_field_rev(field_id).await;
if result.is_none() {
tracing::warn!("Can't find the field with id: {}", field_id);
@@ -307,10 +303,6 @@ impl GridRevisionEditor {
#[tracing::instrument(level = "debug", skip_all, err)]
async fn update_field_rev(&self, params: FieldChangesetParams, field_type: FieldType) -> FlowyResult<()> {
let mut is_type_option_changed = false;
if !params.has_changes() {
return Ok(());
}
let _ = self
.modify(|grid| {
let changeset = grid.modify_field(&params.field_id, |field| {
@@ -334,11 +326,11 @@ impl GridRevisionEditor {
}
if let Some(type_option_data) = params.type_option_data {
let deserializer = TypeOptionJsonDeserializer(field_type);
is_type_option_changed = true;
match deserializer.deserialize(type_option_data) {
Ok(json_str) => {
let field_type = field.ty;
field.insert_type_option_str(&field_type, json_str);
is_type_option_changed = true;
}
Err(err) => {
tracing::error!("Deserialize data to type option json failed: {}", err);
@@ -12,7 +12,14 @@ pub struct AttributeEntry {
}
impl AttributeEntry {
pub fn remove_value(&mut self) {
pub fn new<K: Into<AttributeKey>, V: Into<AttributeValue>>(key: K, value: V) -> Self {
Self {
key: key.into(),
value: value.into(),
}
}
pub fn clear(&mut self) {
self.value.ty = None;
self.value.value = None;
}
@@ -108,6 +115,10 @@ impl AttributeHashMap {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn to_json(&self) -> Result<String, OTError> {
serde_json::to_string(self).map_err(|err| OTError::serde().context(err))
}
}
impl Display for AttributeHashMap {
@@ -203,11 +214,10 @@ impl AttributeValue {
pub fn none() -> Self {
Self { ty: None, value: None }
}
pub fn from_int(val: usize) -> Self {
let value = if val > 0_usize { Some(val.to_string()) } else { None };
pub fn from_int(val: i64) -> Self {
Self {
ty: Some(ValueType::IntType),
value,
value: Some(val.to_string()),
}
}
@@ -261,7 +271,7 @@ impl std::convert::From<bool> for AttributeValue {
impl std::convert::From<usize> for AttributeValue {
fn from(value: usize) -> Self {
AttributeValue::from_int(value)
AttributeValue::from_int(value as i64)
}
}
@@ -277,6 +287,12 @@ impl std::convert::From<String> for AttributeValue {
}
}
impl std::convert::From<f64> for AttributeValue {
fn from(value: f64) -> Self {
AttributeValue::from_float(value)
}
}
#[derive(Default)]
pub struct AttributeBuilder {
attributes: AttributeHashMap,
@@ -70,56 +70,70 @@ impl<'de> Deserialize<'de> for AttributeValue {
where
E: de::Error,
{
Ok(AttributeValue::from_int(value as usize))
Ok(AttributeValue::from_int(value as i64))
}
fn visit_i16<E>(self, value: i16) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AttributeValue::from_int(value as usize))
Ok(AttributeValue::from_int(value as i64))
}
fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AttributeValue::from_int(value as usize))
Ok(AttributeValue::from_int(value as i64))
}
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AttributeValue::from_int(value as usize))
Ok(AttributeValue::from_int(value as i64))
}
fn visit_u8<E>(self, value: u8) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AttributeValue::from_int(value as usize))
Ok(AttributeValue::from_int(value as i64))
}
fn visit_u16<E>(self, value: u16) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AttributeValue::from_int(value as usize))
Ok(AttributeValue::from_int(value as i64))
}
fn visit_u32<E>(self, value: u32) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AttributeValue::from_int(value as usize))
Ok(AttributeValue::from_int(value as i64))
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AttributeValue::from_int(value as usize))
Ok(AttributeValue::from_int(value as i64))
}
fn visit_f32<E>(self, value: f32) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AttributeValue::from_float(value as f64))
}
fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(AttributeValue::from_float(value as f64))
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
+6 -6
View File
@@ -1,6 +1,5 @@
use crate::core::delta::operation::OperationAttributes;
use crate::core::delta::{trim, DeltaOperations};
use crate::core::DeltaOperation;
/// A builder for creating new [Operations] objects.
///
@@ -39,12 +38,13 @@ where
DeltaOperationBuilder::default()
}
pub fn from_operations(operations: Vec<DeltaOperation<T>>) -> DeltaOperations<T> {
let mut delta = DeltaOperationBuilder::default().build();
operations.into_iter().for_each(|operation| {
delta.add(operation);
pub fn from_delta_operation(delta_operation: DeltaOperations<T>) -> Self {
debug_assert!(delta_operation.utf16_base_len == 0);
let mut builder = DeltaOperationBuilder::new();
delta_operation.ops.into_iter().for_each(|operation| {
builder.delta.add(operation);
});
delta
builder
}
/// Retain the 'n' characters with the attributes. Use 'retain' instead if you don't
@@ -1,51 +0,0 @@
use crate::core::delta::operation::{DeltaOperation, EmptyAttributes, OperationAttributes};
// pub type RichTextOpBuilder = OperationsBuilder<TextAttributes>;
pub type PlainTextOpBuilder = OperationsBuilder<EmptyAttributes>;
#[derive(Default)]
pub struct OperationsBuilder<T: OperationAttributes> {
operations: Vec<DeltaOperation<T>>,
}
impl<T> OperationsBuilder<T>
where
T: OperationAttributes,
{
pub fn new() -> OperationsBuilder<T> {
OperationsBuilder::default()
}
pub fn retain_with_attributes(mut self, n: usize, attributes: T) -> OperationsBuilder<T> {
let retain = DeltaOperation::retain_with_attributes(n, attributes);
self.operations.push(retain);
self
}
pub fn retain(mut self, n: usize) -> OperationsBuilder<T> {
let retain = DeltaOperation::retain(n);
self.operations.push(retain);
self
}
pub fn delete(mut self, n: usize) -> OperationsBuilder<T> {
self.operations.push(DeltaOperation::Delete(n));
self
}
pub fn insert_with_attributes(mut self, s: &str, attributes: T) -> OperationsBuilder<T> {
let insert = DeltaOperation::insert_with_attributes(s, attributes);
self.operations.push(insert);
self
}
pub fn insert(mut self, s: &str) -> OperationsBuilder<T> {
let insert = DeltaOperation::insert(s);
self.operations.push(insert);
self
}
pub fn build(self) -> Vec<DeltaOperation<T>> {
self.operations
}
}
@@ -1,8 +1,6 @@
#![allow(clippy::module_inception)]
mod builder;
mod operation;
mod operation_serde;
pub use builder::*;
pub use operation::*;
pub use operation_serde::*;
+4
View File
@@ -224,6 +224,10 @@ where
Ok(new_s)
}
pub fn inverted(&self) -> Self {
self.invert_str("")
}
/// Computes the inverse [Delta]. The inverse of an operation is the
/// operation that reverts the effects of the operation
/// # Arguments
@@ -3,6 +3,7 @@
mod node;
mod node_serde;
mod operation;
mod operation_serde;
mod path;
mod transaction;
mod transaction_serde;
+57 -6
View File
@@ -1,7 +1,7 @@
use super::node_serde::*;
use crate::core::attributes::{AttributeHashMap, AttributeKey, AttributeValue};
use crate::core::Body::Delta;
use crate::core::OperationTransform;
use crate::core::{AttributeEntry, OperationTransform};
use crate::errors::OTError;
use crate::text_delta::DeltaTextOperations;
use serde::{Deserialize, Serialize};
@@ -69,14 +69,18 @@ impl NodeDataBuilder {
/// Inserts attributes to the builder's node.
///
/// The attributes will be replace if they shared the same key
pub fn insert_attribute(mut self, key: AttributeKey, value: AttributeValue) -> Self {
self.node.attributes.insert(key, value);
pub fn insert_attribute<K: Into<AttributeKey>, V: Into<AttributeValue>>(mut self, key: K, value: V) -> Self {
self.node.attributes.insert(key.into(), value);
self
}
/// Inserts a body to the builder's node
pub fn insert_body(mut self, body: Body) -> Self {
self.node.body = body;
pub fn insert_attribute_entry(mut self, entry: AttributeEntry) -> Self {
self.node.attributes.insert_entry(entry);
self
}
pub fn insert_delta(mut self, delta: DeltaTextOperations) -> Self {
self.node.body = Body::Delta(delta);
self
}
@@ -174,6 +178,18 @@ pub enum Changeset {
}
impl Changeset {
pub fn is_delta(&self) -> bool {
match self {
Changeset::Delta { .. } => true,
Changeset::Attributes { .. } => false,
}
}
pub fn is_attribute(&self) -> bool {
match self {
Changeset::Delta { .. } => false,
Changeset::Attributes { .. } => true,
}
}
pub fn inverted(&self) -> Changeset {
match self {
Changeset::Delta { delta, inverted } => Changeset::Delta {
@@ -186,6 +202,41 @@ impl Changeset {
},
}
}
pub fn compose(&mut self, other: &Changeset) -> Result<(), OTError> {
match (self, other) {
(
Changeset::Delta { delta, inverted },
Changeset::Delta {
delta: other_delta,
inverted: _,
},
) => {
let original = delta.compose(inverted)?;
let new_delta = delta.compose(other_delta)?;
let new_inverted = new_delta.invert(&original);
*delta = new_delta;
*inverted = new_inverted;
Ok(())
}
(
Changeset::Attributes { new, old },
Changeset::Attributes {
new: other_new,
old: other_old,
},
) => {
*new = other_new.clone();
*old = other_old.clone();
Ok(())
}
(left, right) => {
let err = format!("Compose changeset failed. {:?} can't compose {:?}", left, right);
Err(OTError::compose().context(err))
}
}
}
}
/// [`Node`] represents as a leaf in the [`NodeTree`].
+142 -41
View File
@@ -1,5 +1,6 @@
use crate::core::{Changeset, NodeData, Path};
use crate::core::{Body, Changeset, NodeData, OperationTransform, Path};
use crate::errors::OTError;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -33,7 +34,80 @@ impl NodeOperation {
}
}
pub fn invert(&self) -> NodeOperation {
pub fn is_update_delta(&self) -> bool {
match self {
NodeOperation::Insert { .. } => false,
NodeOperation::Update { path: _, changeset } => changeset.is_delta(),
NodeOperation::Delete { .. } => false,
}
}
pub fn is_update_attribute(&self) -> bool {
match self {
NodeOperation::Insert { .. } => false,
NodeOperation::Update { path: _, changeset } => changeset.is_attribute(),
NodeOperation::Delete { .. } => false,
}
}
pub fn is_insert(&self) -> bool {
match self {
NodeOperation::Insert { .. } => true,
NodeOperation::Update { .. } => false,
NodeOperation::Delete { .. } => false,
}
}
pub fn can_compose(&self, other: &NodeOperation) -> bool {
if self.get_path() != other.get_path() {
return false;
}
if self.is_update_delta() && other.is_update_delta() {
return true;
}
if self.is_update_attribute() && other.is_update_attribute() {
return true;
}
if self.is_insert() && other.is_update_delta() {
return true;
}
false
}
pub fn compose(&mut self, other: &NodeOperation) -> Result<(), OTError> {
match (self, other) {
(
NodeOperation::Insert { path: _, nodes },
NodeOperation::Update {
path: _other_path,
changeset,
},
) => {
match changeset {
Changeset::Delta { delta, inverted: _ } => {
if let Body::Delta(old_delta) = &mut nodes.last_mut().unwrap().body {
let new_delta = old_delta.compose(delta)?;
*old_delta = new_delta;
}
}
Changeset::Attributes { new: _, old: _ } => {
return Err(OTError::compose().context("Can't compose the attributes changeset"));
}
}
Ok(())
}
(
NodeOperation::Update { path: _, changeset },
NodeOperation::Update {
path: _,
changeset: other_changeset,
},
) => changeset.compose(other_changeset),
(_left, _right) => Err(OTError::compose().context("Can't compose the operation")),
}
}
pub fn inverted(&self) -> NodeOperation {
match self {
NodeOperation::Insert { path, nodes } => NodeOperation::Delete {
path: path.clone(),
@@ -99,52 +173,24 @@ impl NodeOperation {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
type OperationIndexMap = Vec<Arc<NodeOperation>>;
#[derive(Debug, Clone, Default)]
pub struct NodeOperations {
operations: Vec<Arc<NodeOperation>>,
inner: OperationIndexMap,
}
impl NodeOperations {
pub fn into_inner(self) -> Vec<Arc<NodeOperation>> {
self.operations
pub fn new() -> Self {
Self::default()
}
pub fn push_op(&mut self, operation: NodeOperation) {
self.operations.push(Arc::new(operation));
}
pub fn extend(&mut self, other: NodeOperations) {
for operation in other.operations {
self.operations.push(operation);
}
}
}
impl std::ops::Deref for NodeOperations {
type Target = Vec<Arc<NodeOperation>>;
fn deref(&self) -> &Self::Target {
&self.operations
}
}
impl std::ops::DerefMut for NodeOperations {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.operations
}
}
impl std::convert::From<Vec<NodeOperation>> for NodeOperations {
fn from(operations: Vec<NodeOperation>) -> Self {
Self::new(operations)
}
}
impl NodeOperations {
pub fn new(operations: Vec<NodeOperation>) -> Self {
Self {
operations: operations.into_iter().map(Arc::new).collect(),
pub fn from_operations(operations: Vec<NodeOperation>) -> Self {
let mut ops = Self::new();
for op in operations {
ops.push_op(op)
}
ops
}
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, OTError> {
@@ -156,4 +202,59 @@ impl NodeOperations {
let bytes = serde_json::to_vec(self).map_err(|err| OTError::serde().context(err))?;
Ok(bytes)
}
pub fn values(&self) -> &Vec<Arc<NodeOperation>> {
&self.inner
}
pub fn values_mut(&mut self) -> &mut Vec<Arc<NodeOperation>> {
&mut self.inner
}
pub fn len(&self) -> usize {
self.values().len()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
pub fn into_inner(self) -> Vec<Arc<NodeOperation>> {
self.inner
}
pub fn push_op<T: Into<Arc<NodeOperation>>>(&mut self, other: T) {
let other = other.into();
if let Some(last_operation) = self.inner.last_mut() {
if last_operation.can_compose(&other) {
let mut_operation = Arc::make_mut(last_operation);
if mut_operation.compose(&other).is_ok() {
return;
}
}
}
// If the passed-in operation can't be composed, then append it to the end.
self.inner.push(other);
}
pub fn compose(&mut self, other: NodeOperations) {
for operation in other.values() {
self.push_op(operation.clone());
}
}
pub fn inverted(&self) -> Self {
let mut operations = Self::new();
for operation in self.values() {
operations.push_op(operation.inverted());
}
operations
}
}
impl std::convert::From<Vec<NodeOperation>> for NodeOperations {
fn from(operations: Vec<NodeOperation>) -> Self {
Self::from_operations(operations)
}
}
@@ -1,196 +1,49 @@
use crate::core::{AttributeHashMap, Changeset, Path};
use crate::text_delta::TextOperations;
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserializer, Serializer};
use std::convert::TryInto;
use crate::core::{NodeOperation, NodeOperations};
use serde::de::{SeqAccess, Visitor};
use serde::ser::SerializeSeq;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::marker::PhantomData;
#[allow(dead_code)]
pub fn serialize_changeset<S>(path: &Path, changeset: &Changeset, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(3))?;
map.serialize_key("path")?;
map.serialize_value(path)?;
match changeset {
Changeset::Delta { delta, inverted } => {
map.serialize_key("delta")?;
map.serialize_value(delta)?;
map.serialize_key("inverted")?;
map.serialize_value(inverted)?;
map.end()
}
Changeset::Attributes { new, old } => {
map.serialize_key("new")?;
map.serialize_value(new)?;
map.serialize_key("old")?;
map.serialize_value(old)?;
map.end()
impl Serialize for NodeOperations {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let operations = self.values();
let mut seq = serializer.serialize_seq(Some(operations.len()))?;
for operation in operations {
let _ = seq.serialize_element(&operation)?;
}
seq.end()
}
}
#[allow(dead_code)]
pub fn deserialize_changeset<'de, D>(deserializer: D) -> Result<(Path, Changeset), D::Error>
where
D: Deserializer<'de>,
{
struct ChangesetVisitor();
impl<'de> Deserialize<'de> for NodeOperations {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct NodeOperationsVisitor();
impl<'de> Visitor<'de> for ChangesetVisitor {
type Value = (Path, Changeset);
impl<'de> Visitor<'de> for NodeOperationsVisitor {
type Value = NodeOperations;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("Expect Path and Changeset")
}
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("Expected node operation")
}
#[inline]
fn visit_map<V>(self, mut map: V) -> Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
let mut path: Option<Path> = None;
let mut delta_changeset = DeltaChangeset::<V::Error>::new();
let mut attribute_changeset = AttributeChangeset::new();
while let Some(key) = map.next_key()? {
match key {
"delta" => {
if delta_changeset.delta.is_some() {
return Err(de::Error::duplicate_field("delta"));
}
delta_changeset.delta = Some(map.next_value()?);
}
"inverted" => {
if delta_changeset.inverted.is_some() {
return Err(de::Error::duplicate_field("inverted"));
}
delta_changeset.inverted = Some(map.next_value()?);
}
"path" => {
if path.is_some() {
return Err(de::Error::duplicate_field("path"));
}
path = Some(map.next_value::<Path>()?)
}
"new" => {
if attribute_changeset.new.is_some() {
return Err(de::Error::duplicate_field("new"));
}
attribute_changeset.new = Some(map.next_value()?);
}
"old" => {
if attribute_changeset.old.is_some() {
return Err(de::Error::duplicate_field("old"));
}
attribute_changeset.old = Some(map.next_value()?);
}
other => {
tracing::warn!("Unexpected key: {}", other);
panic!()
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut operations = NodeOperations::new();
while let Some(operation) = seq.next_element::<NodeOperation>()? {
operations.push_op(operation);
}
Ok(operations)
}
if path.is_none() {
return Err(de::Error::missing_field("path"));
}
let mut changeset: Changeset;
if !delta_changeset.is_empty() {
changeset = delta_changeset.try_into()?
} else {
changeset = attribute_changeset.try_into()?;
}
Ok((path.unwrap(), changeset))
}
}
deserializer.deserialize_any(ChangesetVisitor())
}
struct DeltaChangeset<E> {
delta: Option<TextOperations>,
inverted: Option<TextOperations>,
error: PhantomData<E>,
}
impl<E> DeltaChangeset<E> {
fn new() -> Self {
Self {
delta: None,
inverted: None,
error: PhantomData,
}
}
fn is_empty(&self) -> bool {
self.delta.is_none() && self.inverted.is_none()
}
}
impl<E> std::convert::TryInto<Changeset> for DeltaChangeset<E>
where
E: de::Error,
{
type Error = E;
fn try_into(self) -> Result<Changeset, Self::Error> {
if self.delta.is_none() {
return Err(de::Error::missing_field("delta"));
}
if self.inverted.is_none() {
return Err(de::Error::missing_field("inverted"));
}
let changeset = Changeset::Delta {
delta: self.delta.unwrap(),
inverted: self.inverted.unwrap(),
};
Ok(changeset)
}
}
struct AttributeChangeset<E> {
new: Option<AttributeHashMap>,
old: Option<AttributeHashMap>,
error: PhantomData<E>,
}
impl<E> AttributeChangeset<E> {
fn new() -> Self {
Self {
new: Default::default(),
old: Default::default(),
error: PhantomData,
}
}
fn is_empty(&self) -> bool {
self.new.is_none() && self.old.is_none()
}
}
impl<E> std::convert::TryInto<Changeset> for AttributeChangeset<E>
where
E: de::Error,
{
type Error = E;
fn try_into(self) -> Result<Changeset, Self::Error> {
if self.new.is_none() {
return Err(de::Error::missing_field("new"));
}
if self.old.is_none() {
return Err(de::Error::missing_field("old"));
}
Ok(Changeset::Attributes {
new: self.new.unwrap(),
old: self.old.unwrap(),
})
deserializer.deserialize_any(NodeOperationsVisitor())
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize};
/// The path of Node A-1 will be [0,0]
/// The path of Node A-2 will be [0,1]
/// The path of Node B-2 will be [1,1]
#[derive(Clone, Serialize, Deserialize, Eq, PartialEq, Debug, Default)]
#[derive(Clone, Serialize, Deserialize, Eq, PartialEq, Debug, Default, Hash)]
pub struct Path(pub Vec<usize>);
impl Path {
@@ -1,13 +1,12 @@
use super::{Changeset, NodeOperations};
use crate::core::attributes::AttributeHashMap;
use crate::core::{NodeData, NodeOperation, NodeTree, Path};
use crate::errors::OTError;
use indextree::NodeId;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Transaction {
#[serde(flatten)]
pub operations: NodeOperations,
#[serde(default)]
@@ -37,6 +36,16 @@ impl Transaction {
Ok(bytes)
}
pub fn from_json(s: &str) -> Result<Self, OTError> {
let serde_transaction: Transaction = serde_json::from_str(s).map_err(|err| OTError::serde().context(err))?;
let mut transaction = Self::new();
transaction.extension = serde_transaction.extension;
for operation in serde_transaction.operations.into_inner() {
transaction.operations.push_op(operation);
}
Ok(transaction)
}
pub fn to_json(&self) -> Result<String, OTError> {
serde_json::to_string(&self).map_err(|err| OTError::serde().context(err))
}
@@ -45,6 +54,10 @@ impl Transaction {
self.operations.into_inner()
}
pub fn split(self) -> (Vec<Arc<NodeOperation>>, Extension) {
(self.operations.into_inner(), self.extension)
}
pub fn push_operation<T: Into<NodeOperation>>(&mut self, operation: T) {
let operation = operation.into();
self.operations.push_op(operation);
@@ -57,38 +70,26 @@ impl Transaction {
pub fn transform(&self, other: &Transaction) -> Result<Transaction, OTError> {
let mut other = other.clone();
other.extension = self.extension.clone();
for other_operation in other.iter_mut() {
for other_operation in other.operations.values_mut() {
let other_operation = Arc::make_mut(other_operation);
for operation in self.operations.iter() {
for operation in self.operations.values() {
operation.transform(other_operation);
}
}
Ok(other)
}
pub fn compose(&mut self, other: Transaction) -> Result<(), OTError> {
// For the moment, just append `other` operations to the end of `self`.
let Transaction { operations, extension } = other;
self.operations.extend(operations);
self.operations.compose(operations);
self.extension = extension;
Ok(())
}
}
impl std::ops::Deref for Transaction {
type Target = Vec<Arc<NodeOperation>>;
fn deref(&self) -> &Self::Target {
&self.operations
}
}
impl std::ops::DerefMut for Transaction {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.operations
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Extension {
Empty,
@@ -121,18 +122,14 @@ pub struct Position {
path: Path,
offset: usize,
}
pub struct TransactionBuilder<'a> {
node_tree: &'a NodeTree,
#[derive(Default)]
pub struct TransactionBuilder {
operations: NodeOperations,
}
impl<'a> TransactionBuilder<'a> {
pub fn new(node_tree: &'a NodeTree) -> TransactionBuilder {
TransactionBuilder {
node_tree,
operations: NodeOperations::default(),
}
impl TransactionBuilder {
pub fn new() -> TransactionBuilder {
Self::default()
}
///
@@ -148,9 +145,9 @@ impl<'a> TransactionBuilder<'a> {
/// // 0 -- text_1
/// use lib_ot::core::{NodeTree, NodeData, TransactionBuilder};
/// let mut node_tree = NodeTree::default();
/// let transaction = TransactionBuilder::new(&node_tree)
/// let transaction = TransactionBuilder::new()
/// .insert_nodes_at_path(0,vec![ NodeData::new("text_1")])
/// .finalize();
/// .build();
/// node_tree.apply_transaction(transaction).unwrap();
///
/// node_tree.node_id_at_path(vec![0]).unwrap();
@@ -178,9 +175,9 @@ impl<'a> TransactionBuilder<'a> {
/// // |-- text
/// use lib_ot::core::{NodeTree, NodeData, TransactionBuilder};
/// let mut node_tree = NodeTree::default();
/// let transaction = TransactionBuilder::new(&node_tree)
/// let transaction = TransactionBuilder::new()
/// .insert_node_at_path(0, NodeData::new("text"))
/// .finalize();
/// .build();
/// node_tree.apply_transaction(transaction).unwrap();
/// ```
///
@@ -188,49 +185,50 @@ impl<'a> TransactionBuilder<'a> {
self.insert_nodes_at_path(path, vec![node])
}
pub fn update_attributes_at_path(mut self, path: &Path, attributes: AttributeHashMap) -> Self {
match self.node_tree.get_node_at_path(path) {
Some(node) => {
let mut old_attributes = AttributeHashMap::new();
for key in attributes.keys() {
let old_attrs = &node.attributes;
if let Some(value) = old_attrs.get(key.as_str()) {
old_attributes.insert(key.clone(), value.clone());
}
}
self.operations.push_op(NodeOperation::Update {
path: path.clone(),
changeset: Changeset::Attributes {
new: attributes,
old: old_attributes,
},
});
}
None => tracing::warn!("Update attributes at path: {:?} failed. Node is not exist", path),
}
pub fn update_node_at_path<T: Into<Path>>(mut self, path: T, changeset: Changeset) -> Self {
self.operations.push_op(NodeOperation::Update {
path: path.into(),
changeset,
});
self
}
//
// pub fn update_delta_at_path<T: Into<Path>>(
// mut self,
// path: T,
// new_delta: DeltaTextOperations,
// ) -> Result<Self, OTError> {
// let path = path.into();
// let operation: NodeOperation = self
// .operations
// .get(&path)
// .ok_or(Err(OTError::record_not_found().context("Can't found the node")))?;
//
// match operation {
// NodeOperation::Insert { path, nodes } => {}
// NodeOperation::Update { path, changeset } => {}
// NodeOperation::Delete { .. } => {}
// }
//
// match node.body {
// Body::Empty => Ok(self),
// Body::Delta(delta) => {
// let inverted = new_delta.invert(&delta);
// let changeset = Changeset::Delta {
// delta: new_delta,
// inverted,
// };
// Ok(self.update_node_at_path(path, changeset))
// }
// }
// }
pub fn update_body_at_path(mut self, path: &Path, changeset: Changeset) -> Self {
match self.node_tree.node_id_at_path(path) {
Some(_) => {
self.operations.push_op(NodeOperation::Update {
path: path.clone(),
changeset,
});
}
None => tracing::warn!("Update attributes at path: {:?} failed. Node is not exist", path),
}
self
pub fn delete_node_at_path(self, node_tree: &NodeTree, path: &Path) -> Self {
self.delete_nodes_at_path(node_tree, path, 1)
}
pub fn delete_node_at_path(self, path: &Path) -> Self {
self.delete_nodes_at_path(path, 1)
}
pub fn delete_nodes_at_path(mut self, path: &Path, length: usize) -> Self {
let node_id = self.node_tree.node_id_at_path(path);
pub fn delete_nodes_at_path(mut self, node_tree: &NodeTree, path: &Path, length: usize) -> Self {
let node_id = node_tree.node_id_at_path(path);
if node_id.is_none() {
tracing::warn!("Path: {:?} doesn't contains any nodes", path);
return self;
@@ -239,8 +237,8 @@ impl<'a> TransactionBuilder<'a> {
let mut node_id = node_id.unwrap();
let mut deleted_nodes = vec![];
for _ in 0..length {
deleted_nodes.push(self.get_deleted_node_data(node_id));
node_id = self.node_tree.following_siblings(node_id).next().unwrap();
deleted_nodes.push(self.get_deleted_node_data(node_tree, node_id));
node_id = node_tree.following_siblings(node_id).next().unwrap();
}
self.operations.push_op(NodeOperation::Delete {
@@ -250,16 +248,12 @@ impl<'a> TransactionBuilder<'a> {
self
}
fn get_deleted_node_data(&self, node_id: NodeId) -> NodeData {
let node_data = self.node_tree.get_node(node_id).unwrap();
fn get_deleted_node_data(&self, node_tree: &NodeTree, node_id: NodeId) -> NodeData {
let node_data = node_tree.get_node(node_id).unwrap();
let mut children = vec![];
self.node_tree
.get_children_ids(node_id)
.into_iter()
.for_each(|child_id| {
children.push(self.get_deleted_node_data(child_id));
});
node_tree.get_children_ids(node_id).into_iter().for_each(|child_id| {
children.push(self.get_deleted_node_data(node_tree, child_id));
});
NodeData {
node_type: node_data.node_type.clone(),
@@ -274,7 +268,7 @@ impl<'a> TransactionBuilder<'a> {
self
}
pub fn finalize(self) -> Transaction {
pub fn build(self) -> Transaction {
Transaction::from_operations(self.operations)
}
}
+94 -26
View File
@@ -1,6 +1,6 @@
use super::NodeOperations;
use crate::core::{Changeset, Node, NodeData, NodeOperation, Path, Transaction};
use crate::errors::{ErrorBuilder, OTError, OTErrorCode};
use crate::errors::{OTError, OTErrorCode};
use indextree::{Arena, FollowingSiblings, NodeId};
use std::sync::Arc;
@@ -20,6 +20,8 @@ impl Default for NodeTree {
}
}
pub const PLACEHOLDER_NODE_TYPE: &str = "";
impl NodeTree {
pub fn new(context: NodeTreeContext) -> NodeTree {
let mut arena = Arena::new();
@@ -41,7 +43,7 @@ impl NodeTree {
pub fn from_operations<T: Into<NodeOperations>>(operations: T, context: NodeTreeContext) -> Result<Self, OTError> {
let operations = operations.into();
let mut node_tree = NodeTree::new(context);
for operation in operations.into_inner().into_iter() {
for (_, operation) in operations.into_inner().into_iter().enumerate() {
let _ = node_tree.apply_op(operation)?;
}
Ok(node_tree)
@@ -149,15 +151,15 @@ impl NodeTree {
return None;
}
let mut iterate_node = self.root;
let mut node_id = self.root;
for id in path.iter() {
iterate_node = self.child_from_node_at_index(iterate_node, *id)?;
node_id = self.node_id_from_parent_at_index(node_id, *id)?;
}
if iterate_node.is_removed(&self.arena) {
if node_id.is_removed(&self.arena) {
return None;
}
Some(iterate_node)
Some(node_id)
}
pub fn path_from_node_id(&self, node_id: NodeId) -> Path {
@@ -210,7 +212,7 @@ impl NodeTree {
/// let node_2 = node_tree.get_node_at_path(&inserted_path).unwrap();
/// assert_eq!(node_2.node_type, node_1.node_type);
/// ```
pub fn child_from_node_at_index(&self, node_id: NodeId, index: usize) -> Option<NodeId> {
pub fn node_id_from_parent_at_index(&self, node_id: NodeId, index: usize) -> Option<NodeId> {
let children = node_id.children(&self.arena);
for (counter, child) in children.enumerate() {
if counter == index {
@@ -240,10 +242,11 @@ impl NodeTree {
}
pub fn apply_transaction(&mut self, transaction: Transaction) -> Result<(), OTError> {
let operations = transaction.into_operations();
let operations = transaction.split().0;
for operation in operations {
self.apply_op(operation)?;
}
Ok(())
}
@@ -294,25 +297,68 @@ impl NodeTree {
if parent_path.is_empty() {
self.insert_nodes_at_index(self.root, last_index, nodes)
} else {
let parent_node = self
.node_id_at_path(parent_path)
.ok_or_else(|| ErrorBuilder::new(OTErrorCode::PathNotFound).build())?;
let parent_node = match self.node_id_at_path(parent_path) {
None => self.create_adjacent_nodes_for_path(parent_path),
Some(parent_node) => parent_node,
};
self.insert_nodes_at_index(parent_node, last_index, nodes)
}
}
/// Create the adjacent nodes for the path
///
/// It will create a corresponding node for each node on the path if it's not existing.
/// If the path is not start from zero, it will create its siblings.
///
/// Check out the operation_insert_test.rs for more examples.
/// * operation_insert_node_when_its_parent_is_not_exist
/// * operation_insert_node_when_multiple_parent_is_not_exist_test
///
/// # Arguments
///
/// * `path`: creates nodes for this path
///
/// returns: NodeId
///
fn create_adjacent_nodes_for_path<T: Into<Path>>(&mut self, path: T) -> NodeId {
let path = path.into();
let mut node_id = self.root;
for id in path.iter() {
match self.node_id_from_parent_at_index(node_id, *id) {
None => {
let num_of_children = node_id.children(&self.arena).count();
if *id > num_of_children {
for _ in 0..(*id - num_of_children) {
let node: Node = placeholder_node().into();
let sibling_node = self.arena.new_node(node);
node_id.append(sibling_node, &mut self.arena);
}
}
let node: Node = placeholder_node().into();
let new_node_id = self.arena.new_node(node);
node_id.append(new_node_id, &mut self.arena);
node_id = new_node_id;
}
Some(next_node_id) => {
node_id = next_node_id;
}
}
}
node_id
}
/// Inserts nodes before the node with node_id
///
fn insert_nodes_before(&mut self, node_id: &NodeId, nodes: Vec<NodeData>) {
if node_id.is_removed(&self.arena) {
tracing::warn!("Node:{:?} is remove before insert", node_id);
return;
}
for node in nodes {
let (node, children) = node.split();
let new_node_id = self.arena.new_node(node);
if node_id.is_removed(&self.arena) {
tracing::warn!("Node:{:?} is remove before insert", node_id);
return;
}
node_id.insert_before(new_node_id, &mut self.arena);
self.append_nodes(&new_node_id, children);
}
@@ -326,14 +372,21 @@ impl NodeTree {
// Append the node to the end of the children list if index greater or equal to the
// length of the children.
if index >= parent.children(&self.arena).count() {
let num_of_children = parent.children(&self.arena).count();
if index >= num_of_children {
let mut num_of_nodes_to_insert = index - num_of_children;
while num_of_nodes_to_insert > 0 {
self.append_nodes(&parent, vec![placeholder_node()]);
num_of_nodes_to_insert -= 1;
}
self.append_nodes(&parent, nodes);
return Ok(());
}
let node_to_insert = self
.child_from_node_at_index(parent, index)
.ok_or_else(|| ErrorBuilder::new(OTErrorCode::PathNotFound).build())?;
.node_id_from_parent_at_index(parent, index)
.ok_or_else(|| OTError::internal().context(format!("Can't find the node at {}", index)))?;
self.insert_nodes_before(&node_to_insert, nodes);
Ok(())
@@ -364,11 +417,22 @@ impl NodeTree {
Ok(())
}
/// Update the node at path with the `changeset`
///
/// Do nothing if there is no node at the path.
///
/// # Arguments
///
/// * `path`: references to the node that will be applied with the changeset
/// * `changeset`: the change that will be applied to the node
///
/// returns: Result<(), OTError>
fn update(&mut self, path: &Path, changeset: Changeset) -> Result<(), OTError> {
self.mut_node_at_path(path, |node| {
let _ = node.apply_changeset(changeset)?;
Ok(())
})
match self.mut_node_at_path(path, |node| node.apply_changeset(changeset)) {
Ok(_) => {}
Err(err) => tracing::error!("{}", err),
}
Ok(())
}
fn mut_node_at_path<F>(&mut self, path: &Path, f: F) -> Result<(), OTError>
@@ -378,9 +442,9 @@ impl NodeTree {
if !path.is_valid() {
return Err(OTErrorCode::InvalidPath.into());
}
let node_id = self
.node_id_at_path(path)
.ok_or_else(|| ErrorBuilder::new(OTErrorCode::PathNotFound).build())?;
let node_id = self.node_id_at_path(path).ok_or_else(|| {
OTError::path_not_found().context(format!("Can't find the mutated node at path: {:?}", path))
})?;
match self.arena.get_mut(node_id) {
None => tracing::warn!("The path: {:?} does not contain any nodes", path),
Some(node) => {
@@ -391,3 +455,7 @@ impl NodeTree {
Ok(())
}
}
pub fn placeholder_node() -> NodeData {
NodeData::new(PLACEHOLDER_NODE_TYPE)
}
+4 -1
View File
@@ -38,6 +38,9 @@ impl OTError {
static_ot_error!(revision_id_conflict, OTErrorCode::RevisionIDConflict);
static_ot_error!(internal, OTErrorCode::Internal);
static_ot_error!(serde, OTErrorCode::SerdeError);
static_ot_error!(path_not_found, OTErrorCode::PathNotFound);
static_ot_error!(compose, OTErrorCode::ComposeOperationFail);
static_ot_error!(record_not_found, OTErrorCode::RecordNotFound);
}
impl fmt::Display for OTError {
@@ -75,7 +78,7 @@ pub enum OTErrorCode {
PathNotFound,
PathIsEmpty,
InvalidPath,
UnexpectedEmpty,
RecordNotFound,
}
pub struct ErrorBuilder {
@@ -0,0 +1,194 @@
use crate::node::script::NodeScript::*;
use crate::node::script::NodeTest;
use lib_ot::core::{AttributeEntry, Changeset, NodeData, OperationTransform};
use lib_ot::text_delta::DeltaTextOperationBuilder;
#[test]
fn changeset_delta_compose_delta_test() {
// delta 1
let delta_1 = DeltaTextOperationBuilder::new().insert("Hello world").build();
let inverted_1 = delta_1.inverted();
let mut changeset_1 = Changeset::Delta {
delta: delta_1.clone(),
inverted: inverted_1,
};
// delta 2
let delta_2 = DeltaTextOperationBuilder::new()
.retain(delta_1.utf16_target_len)
.insert("!")
.build();
let inverted_2 = delta_2.inverted();
let changeset_2 = Changeset::Delta {
delta: delta_2,
inverted: inverted_2,
};
// compose
changeset_1.compose(&changeset_2).unwrap();
if let Changeset::Delta { delta, inverted } = changeset_1 {
assert_eq!(delta.content().unwrap(), "Hello world!");
let new_delta = delta.compose(&inverted).unwrap();
assert_eq!(new_delta.content().unwrap(), "");
}
}
#[test]
fn operation_compose_delta_changeset_then_invert_test() {
let delta = DeltaTextOperationBuilder::new().insert("Hello world").build();
let inverted = delta.inverted();
let changeset = Changeset::Delta {
delta: delta.clone(),
inverted: inverted.clone(),
};
let mut test = NodeTest::new();
let text_node = NodeData::new("text");
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_node,
rev_id: 1,
},
UpdateBody {
path: 0.into(),
changeset: changeset.clone(),
},
AssertNodeDelta {
path: 0.into(),
expected: delta.clone(),
},
UpdateBody {
path: 0.into(),
changeset: changeset.inverted(),
},
AssertNodeDelta {
path: 0.into(),
expected: delta.compose(&inverted).unwrap(),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_compose_multiple_delta_changeset_then_invert_test() {
// delta 1
let delta_1 = DeltaTextOperationBuilder::new().insert("Hello world").build();
let inverted_1 = delta_1.inverted();
let changeset_1 = Changeset::Delta {
delta: delta_1.clone(),
inverted: inverted_1,
};
// delta 2
let delta_2 = DeltaTextOperationBuilder::new()
.retain(delta_1.utf16_target_len)
.insert("!")
.build();
let inverted_2 = delta_2.inverted();
let changeset_2 = Changeset::Delta {
delta: delta_2.clone(),
inverted: inverted_2,
};
// delta 3
let delta_3 = DeltaTextOperationBuilder::new()
.retain(delta_2.utf16_target_len)
.insert("AppFlowy")
.build();
let inverted_3 = delta_3.inverted();
let changeset_3 = Changeset::Delta {
delta: delta_3.clone(),
inverted: inverted_3,
};
let mut test = NodeTest::new();
let text_node = NodeData::new("text");
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_node,
rev_id: 1,
},
UpdateBody {
path: 0.into(),
changeset: changeset_1.clone(),
},
UpdateBody {
path: 0.into(),
changeset: changeset_2.clone(),
},
UpdateBody {
path: 0.into(),
changeset: changeset_3.clone(),
},
AssertNodeDelta {
path: 0.into(),
expected: delta_1.compose(&delta_2).unwrap().compose(&delta_3).unwrap(),
},
UpdateBody {
path: 0.into(),
changeset: changeset_3.inverted(),
},
AssertNodeDeltaContent {
path: 0.into(),
expected: r#"Hello world!"#,
},
UpdateBody {
path: 0.into(),
changeset: changeset_2.inverted(),
},
AssertNodeDeltaContent {
path: 0.into(),
expected: r#"Hello world"#,
},
UpdateBody {
path: 0.into(),
changeset: changeset_1.inverted(),
},
AssertNodeDeltaContent {
path: 0.into(),
expected: r#""#,
},
];
test.run_scripts(scripts);
}
#[test]
#[should_panic]
fn changeset_delta_compose_attributes_test() {
// delta 1
let delta = DeltaTextOperationBuilder::new().insert("Hello world").build();
let inverted = delta.inverted();
let mut delta_changeset = Changeset::Delta { delta, inverted };
// attributes
let attribute_changeset = Changeset::Attributes {
new: Default::default(),
old: Default::default(),
};
// compose
delta_changeset.compose(&attribute_changeset).unwrap();
}
#[test]
fn changeset_attributes_compose_attributes_test() {
// attributes
let mut changeset_1 = Changeset::Attributes {
new: AttributeEntry::new("bold", true).into(),
old: Default::default(),
};
let changeset_2 = Changeset::Attributes {
new: AttributeEntry::new("Italic", true).into(),
old: Default::default(),
};
// compose
changeset_1.compose(&changeset_2).unwrap();
if let Changeset::Attributes { new, old: _ } = changeset_1 {
assert_eq!(new, AttributeEntry::new("Italic", true).into());
}
}
+7 -1
View File
@@ -1,4 +1,10 @@
mod operation_test;
mod changeset_compose_test;
mod operation_attribute_test;
mod operation_compose_test;
mod operation_delete_test;
mod operation_delta_test;
mod operation_insert_test;
mod script;
mod serde_test;
mod transaction_compose_test;
mod tree_test;
@@ -0,0 +1,64 @@
use crate::node::script::NodeScript::*;
use crate::node::script::NodeTest;
use lib_ot::core::{AttributeEntry, AttributeValue, Changeset, NodeData};
#[test]
fn operation_update_attribute_with_float_value_test() {
let mut test = NodeTest::new();
let text_node = NodeData::new("text");
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_node,
rev_id: 1,
},
UpdateBody {
path: 0.into(),
changeset: Changeset::Attributes {
new: AttributeEntry::new("value", 12.2).into(),
old: Default::default(),
},
},
AssertNodeAttributes {
path: 0.into(),
expected: r#"{"value":12.2}"#,
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_update_attribute_with_negative_value_test() {
let mut test = NodeTest::new();
let text_node = NodeData::new("text");
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_node,
rev_id: 1,
},
UpdateBody {
path: 0.into(),
changeset: Changeset::Attributes {
new: AttributeEntry::new("value", -12.2).into(),
old: Default::default(),
},
},
AssertNodeAttributes {
path: 0.into(),
expected: r#"{"value":-12.2}"#,
},
UpdateBody {
path: 0.into(),
changeset: Changeset::Attributes {
new: AttributeEntry::new("value", AttributeValue::from_int(-12)).into(),
old: Default::default(),
},
},
AssertNodeAttributes {
path: 0.into(),
expected: r#"{"value":-12}"#,
},
];
test.run_scripts(scripts);
}
@@ -0,0 +1,135 @@
use lib_ot::core::{Changeset, NodeOperation};
#[test]
fn operation_insert_compose_delta_update_test() {
let insert_operation = NodeOperation::Insert {
path: 0.into(),
nodes: vec![],
};
let update_operation = NodeOperation::Update {
path: 0.into(),
changeset: Changeset::Delta {
delta: Default::default(),
inverted: Default::default(),
},
};
assert!(insert_operation.can_compose(&update_operation))
}
#[test]
fn operation_insert_compose_attribute_update_test() {
let insert_operation = NodeOperation::Insert {
path: 0.into(),
nodes: vec![],
};
let update_operation = NodeOperation::Update {
path: 0.into(),
changeset: Changeset::Attributes {
new: Default::default(),
old: Default::default(),
},
};
assert!(!insert_operation.can_compose(&update_operation))
}
#[test]
fn operation_insert_compose_update_with_diff_path_test() {
let insert_operation = NodeOperation::Insert {
path: 0.into(),
nodes: vec![],
};
let update_operation = NodeOperation::Update {
path: 1.into(),
changeset: Changeset::Attributes {
new: Default::default(),
old: Default::default(),
},
};
assert!(!insert_operation.can_compose(&update_operation))
}
#[test]
fn operation_insert_compose_insert_operation_test() {
let insert_operation = NodeOperation::Insert {
path: 0.into(),
nodes: vec![],
};
assert!(!insert_operation.can_compose(&NodeOperation::Insert {
path: 0.into(),
nodes: vec![],
}),)
}
#[test]
fn operation_update_compose_insert_operation_test() {
let update_operation = NodeOperation::Update {
path: 0.into(),
changeset: Changeset::Attributes {
new: Default::default(),
old: Default::default(),
},
};
assert!(!update_operation.can_compose(&NodeOperation::Insert {
path: 0.into(),
nodes: vec![],
}))
}
#[test]
fn operation_update_compose_update_test() {
let update_operation_1 = NodeOperation::Update {
path: 0.into(),
changeset: Changeset::Attributes {
new: Default::default(),
old: Default::default(),
},
};
let update_operation_2 = NodeOperation::Update {
path: 0.into(),
changeset: Changeset::Attributes {
new: Default::default(),
old: Default::default(),
},
};
assert!(update_operation_1.can_compose(&update_operation_2));
}
#[test]
fn operation_update_compose_update_with_diff_path_test() {
let update_operation_1 = NodeOperation::Update {
path: 0.into(),
changeset: Changeset::Attributes {
new: Default::default(),
old: Default::default(),
},
};
let update_operation_2 = NodeOperation::Update {
path: 1.into(),
changeset: Changeset::Attributes {
new: Default::default(),
old: Default::default(),
},
};
assert!(!update_operation_1.can_compose(&update_operation_2));
}
#[test]
fn operation_insert_compose_insert_test() {
let insert_operation_1 = NodeOperation::Insert {
path: 0.into(),
nodes: vec![],
};
let insert_operation_2 = NodeOperation::Insert {
path: 0.into(),
nodes: vec![],
};
assert!(!insert_operation_1.can_compose(&insert_operation_2));
}
@@ -0,0 +1,178 @@
use crate::node::script::NodeScript::*;
use crate::node::script::NodeTest;
use lib_ot::core::{Changeset, NodeData, NodeDataBuilder};
#[test]
fn operation_delete_nested_node_test() {
let mut test = NodeTest::new();
let image_a = NodeData::new("image_a");
let image_b = NodeData::new("image_b");
let video_a = NodeData::new("video_a");
let video_b = NodeData::new("video_b");
let image_1 = NodeDataBuilder::new("image_1")
.add_node_data(image_a.clone())
.add_node_data(image_b.clone())
.build();
let video_1 = NodeDataBuilder::new("video_1")
.add_node_data(video_a.clone())
.add_node_data(video_b)
.build();
let text_node_1 = NodeDataBuilder::new("text_1")
.add_node_data(image_1)
.add_node_data(video_1.clone())
.build();
let image_2 = NodeDataBuilder::new("image_2")
.add_node_data(image_a)
.add_node_data(image_b.clone())
.build();
let text_node_2 = NodeDataBuilder::new("text_2").add_node_data(image_2).build();
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_node_1,
rev_id: 1,
},
InsertNode {
path: 1.into(),
node_data: text_node_2,
rev_id: 2,
},
// 0:text_1
// 0:image_1
// 0:image_a
// 1:image_b
// 1:video_1
// 0:video_a
// 1:video_b
// 1:text_2
// 0:image_2
// 0:image_a
// 1:image_b
DeleteNode {
path: vec![0, 0, 0].into(),
rev_id: 3,
},
AssertNode {
path: vec![0, 0, 0].into(),
expected: Some(image_b),
},
AssertNode {
path: vec![0, 1].into(),
expected: Some(video_1),
},
DeleteNode {
path: vec![0, 1, 1].into(),
rev_id: 4,
},
AssertNode {
path: vec![0, 1, 0].into(),
expected: Some(video_a),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_delete_node_with_revision_conflict_test() {
let mut test = NodeTest::new();
let text_1 = NodeDataBuilder::new("text_1").build();
let text_2 = NodeDataBuilder::new("text_2").build();
let text_3 = NodeDataBuilder::new("text_3").build();
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_1.clone(),
rev_id: 1,
},
InsertNode {
path: 1.into(),
node_data: text_2,
rev_id: 2,
},
// The node's in the tree will be:
// 0: text_1
// 2: text_2
//
// The insert action is happened concurrently with the delete action, because they
// share the same rev_id. aka, 3. The delete action is want to delete the node at index 1,
// but it was moved to index 2.
InsertNode {
path: 1.into(),
node_data: text_3.clone(),
rev_id: 3,
},
// 0: text_1
// 1: text_3
// 2: text_2
//
// The path of the delete action will be transformed to a new path that point to the text_2.
// 1 -> 2
DeleteNode {
path: 1.into(),
rev_id: 3,
},
// After perform the delete action, the tree will be:
// 0: text_1
// 1: text_3
AssertNumberOfChildrenAtPath {
path: None,
expected: 2,
},
AssertNode {
path: 0.into(),
expected: Some(text_1),
},
AssertNode {
path: 1.into(),
expected: Some(text_3),
},
AssertNode {
path: 2.into(),
expected: None,
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_update_node_after_delete_test() {
let mut test = NodeTest::new();
let text_1 = NodeDataBuilder::new("text_1").build();
let text_2 = NodeDataBuilder::new("text_2").build();
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_1,
rev_id: 1,
},
InsertNode {
path: 1.into(),
node_data: text_2,
rev_id: 2,
},
DeleteNode {
path: 0.into(),
rev_id: 3,
},
// The node at path 1 is not exist. The following UpdateBody script will do nothing
AssertNode {
path: 1.into(),
expected: None,
},
UpdateBody {
path: 1.into(),
changeset: Changeset::Delta {
delta: Default::default(),
inverted: Default::default(),
},
},
];
test.run_scripts(scripts);
}
@@ -0,0 +1,41 @@
use crate::node::script::NodeScript::{AssertNodeDelta, InsertNode, UpdateBody};
use crate::node::script::{edit_node_delta, NodeTest};
use lib_ot::core::NodeDataBuilder;
use lib_ot::text_delta::DeltaTextOperationBuilder;
#[test]
fn operation_update_delta_test() {
let mut test = NodeTest::new();
let initial_delta = DeltaTextOperationBuilder::new().build();
let new_delta = DeltaTextOperationBuilder::new()
.retain(initial_delta.utf16_base_len)
.insert("Hello, world")
.build();
let (changeset, expected) = edit_node_delta(&initial_delta, new_delta);
let node = NodeDataBuilder::new("text").insert_delta(initial_delta.clone()).build();
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: node,
rev_id: 1,
},
UpdateBody {
path: 0.into(),
changeset: changeset.clone(),
},
AssertNodeDelta {
path: 0.into(),
expected,
},
UpdateBody {
path: 0.into(),
changeset: changeset.inverted(),
},
AssertNodeDelta {
path: 0.into(),
expected: initial_delta,
},
];
test.run_scripts(scripts);
}
@@ -0,0 +1,460 @@
use crate::node::script::NodeScript::*;
use crate::node::script::NodeTest;
use lib_ot::core::{placeholder_node, NodeData, NodeDataBuilder, NodeOperation, Path};
#[test]
fn operation_insert_op_transform_test() {
let node_1 = NodeDataBuilder::new("text_1").build();
let node_2 = NodeDataBuilder::new("text_2").build();
let op_1 = NodeOperation::Insert {
path: Path(vec![0, 1]),
nodes: vec![node_1],
};
let mut insert_2 = NodeOperation::Insert {
path: Path(vec![0, 1]),
nodes: vec![node_2],
};
// let mut node_tree = NodeTree::new("root");
// node_tree.apply_op(insert_1.clone()).unwrap();
op_1.transform(&mut insert_2);
let json = serde_json::to_string(&insert_2).unwrap();
assert_eq!(json, r#"{"op":"insert","path":[0,2],"nodes":[{"type":"text_2"}]}"#);
}
#[test]
fn operation_insert_one_level_path_test() {
let node_data_1 = NodeDataBuilder::new("text_1").build();
let node_data_2 = NodeDataBuilder::new("text_2").build();
let node_data_3 = NodeDataBuilder::new("text_3").build();
let node_3 = node_data_3.clone();
// 0: text_1
// 1: text_2
//
// Insert a new operation with rev_id 2 to index 1,but the index was already taken, so
// it needs to be transformed.
//
// 0: text_1
// 1: text_2
// 2: text_3
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: node_data_1.clone(),
rev_id: 1,
},
InsertNode {
path: 1.into(),
node_data: node_data_2.clone(),
rev_id: 2,
},
InsertNode {
path: 1.into(),
node_data: node_data_3.clone(),
rev_id: 2,
},
AssertNode {
path: 2.into(),
expected: Some(node_3.clone()),
},
];
NodeTest::new().run_scripts(scripts);
// If the rev_id of the node_data_3 is 3. then the tree will be:
// 0: text_1
// 1: text_3
// 2: text_2
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: node_data_1,
rev_id: 1,
},
InsertNode {
path: 1.into(),
node_data: node_data_2,
rev_id: 2,
},
InsertNode {
path: 1.into(),
node_data: node_data_3,
rev_id: 3,
},
AssertNode {
path: 1.into(),
expected: Some(node_3),
},
];
NodeTest::new().run_scripts(scripts);
}
#[test]
fn operation_insert_with_multiple_level_path_test() {
let mut test = NodeTest::new();
let node_data_1 = NodeDataBuilder::new("text_1")
.add_node_data(NodeDataBuilder::new("text_1_1").build())
.add_node_data(NodeDataBuilder::new("text_1_2").build())
.build();
let node_data_2 = NodeDataBuilder::new("text_2")
.add_node_data(NodeDataBuilder::new("text_2_1").build())
.add_node_data(NodeDataBuilder::new("text_2_2").build())
.build();
let node_data_3 = NodeDataBuilder::new("text_3").build();
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: node_data_1,
rev_id: 1,
},
InsertNode {
path: 1.into(),
node_data: node_data_2,
rev_id: 2,
},
InsertNode {
path: 1.into(),
node_data: node_data_3.clone(),
rev_id: 2,
},
AssertNode {
path: 2.into(),
expected: Some(node_data_3),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_insert_node_out_of_bound_test() {
let mut test = NodeTest::new();
let image_a = NodeData::new("image_a");
let image_b = NodeData::new("image_b");
let image = NodeDataBuilder::new("image_1")
.add_node_data(image_a)
.add_node_data(image_b)
.build();
let text_node = NodeDataBuilder::new("text_1").add_node_data(image).build();
let image_c = NodeData::new("image_c");
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_node,
rev_id: 1,
},
// 0:text_1
// 0:image_1
// 0:image_a
// 1:image_b
InsertNode {
path: vec![0, 0, 3].into(),
node_data: image_c.clone(),
rev_id: 2,
},
// 0:text_1
// 0:image_1
// 0:image_a
// 1:image_b
// 2:placeholder node
// 3:image_c
AssertNode {
path: vec![0, 0, 2].into(),
expected: Some(placeholder_node()),
},
AssertNode {
path: vec![0, 0, 3].into(),
expected: Some(image_c),
},
AssertNode {
path: vec![0, 0, 10].into(),
expected: None,
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_insert_node_when_parent_is_not_exist_test1() {
let mut test = NodeTest::new();
let text_1 = NodeDataBuilder::new("text_1").build();
let text_2 = NodeDataBuilder::new("text_2").build();
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_1,
rev_id: 1,
},
// The node at path 1 is not existing when inserting the text_2 to path 2.
InsertNode {
path: 2.into(),
node_data: text_2.clone(),
rev_id: 2,
},
AssertNode {
path: 1.into(),
expected: Some(placeholder_node()),
},
AssertNode {
path: 2.into(),
expected: Some(text_2),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_insert_node_when_parent_is_not_exist_test2() {
let mut test = NodeTest::new();
let text_1 = NodeDataBuilder::new("text_1").build();
let text_2 = NodeDataBuilder::new("text_2").build();
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_1,
rev_id: 1,
},
// The node at path 1 is not existing when inserting the text_2 to path 2.
InsertNode {
path: 3.into(),
node_data: text_2.clone(),
rev_id: 2,
},
AssertNode {
path: 1.into(),
expected: Some(placeholder_node()),
},
AssertNode {
path: 2.into(),
expected: Some(placeholder_node()),
},
AssertNode {
path: 3.into(),
expected: Some(text_2),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_insert_node_when_its_parent_is_not_exist_test3() {
let mut test = NodeTest::new();
let text_1 = NodeDataBuilder::new("text_1").build();
let text_2 = NodeDataBuilder::new("text_2").build();
let mut placeholder_node = placeholder_node();
placeholder_node.children.push(text_2.clone());
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_1,
rev_id: 1,
},
// The node at path 1 is not existing when inserting the text_2 to path 2.
InsertNode {
path: vec![1, 0].into(),
node_data: text_2.clone(),
rev_id: 2,
},
AssertNode {
path: 1.into(),
expected: Some(placeholder_node),
},
AssertNode {
path: vec![1, 0].into(),
expected: Some(text_2),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_insert_node_to_the_end_when_parent_is_not_exist_test() {
let mut test = NodeTest::new();
let node_0 = NodeData::new("0");
let node_1 = NodeData::new("1");
let node_1_1 = NodeData::new("1_1");
let text_node = NodeData::new("text");
let mut ghost = placeholder_node();
ghost.children.push(text_node.clone());
// 0:0
// 1:1
// 0:1_1
// 1:ghost
// 0:text
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: node_0,
rev_id: 1,
},
InsertNode {
path: 1.into(),
node_data: node_1,
rev_id: 2,
},
InsertNode {
path: vec![1, 0].into(),
node_data: node_1_1.clone(),
rev_id: 3,
},
InsertNode {
path: vec![1, 1, 0].into(),
node_data: text_node.clone(),
rev_id: 4,
},
AssertNode {
path: vec![1, 0].into(),
expected: Some(node_1_1),
},
AssertNode {
path: vec![1, 1].into(),
expected: Some(ghost),
},
AssertNode {
path: vec![1, 1, 0].into(),
expected: Some(text_node),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_insert_node_when_multiple_parent_is_not_exist_test() {
let mut test = NodeTest::new();
let text_1 = NodeDataBuilder::new("text_1").build();
let text_2 = NodeDataBuilder::new("text_2").build();
let path = vec![1, 0, 0, 0, 0, 0];
let mut auto_fill_node = placeholder_node();
let mut iter_node: &mut NodeData = &mut auto_fill_node;
let insert_path = path.split_at(1).1;
for (index, _) in insert_path.iter().enumerate() {
if index == insert_path.len() - 1 {
iter_node.children.push(text_2.clone());
} else {
iter_node.children.push(placeholder_node());
iter_node = iter_node.children.last_mut().unwrap();
}
}
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_1,
rev_id: 1,
},
InsertNode {
path: path.clone().into(),
node_data: text_2.clone(),
rev_id: 2,
},
AssertNode {
path: vec![1].into(),
expected: Some(auto_fill_node),
},
AssertNode {
path: path.into(),
expected: Some(text_2),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_insert_node_when_multiple_parent_is_not_exist_test2() {
let mut test = NodeTest::new();
// 0:ghost
// 0:ghost
// 1:ghost
// 0:text
let mut text_node_parent = placeholder_node();
let text_node = NodeDataBuilder::new("text").build();
text_node_parent.children.push(text_node.clone());
let mut ghost = placeholder_node();
ghost.children.push(placeholder_node());
ghost.children.push(text_node_parent.clone());
let path = vec![1, 1, 0];
let scripts = vec![
InsertNode {
path: path.into(),
node_data: text_node.clone(),
rev_id: 1,
},
// 0:ghost
// 1:ghost
// 0:ghost
// 1:ghost
// 0:text
AssertNode {
path: 0.into(),
expected: Some(placeholder_node()),
},
AssertNode {
path: 1.into(),
expected: Some(ghost),
},
AssertNumberOfChildrenAtPath {
path: Some(1.into()),
expected: 2,
},
AssertNode {
path: vec![1, 1].into(),
expected: Some(text_node_parent),
},
AssertNode {
path: vec![1, 1, 0].into(),
expected: Some(text_node),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_insert_node_when_multiple_parent_is_not_exist_test3() {
let mut test = NodeTest::new();
let text_node = NodeDataBuilder::new("text").build();
let path = vec![3, 3, 0];
let scripts = vec![
InsertNode {
path: path.clone().into(),
node_data: text_node.clone(),
rev_id: 1,
},
// 0:ghost
// 1:ghost
// 2:ghost
// 3:ghost
// 0:ghost
// 1:ghost
// 2:ghost
// 3:ghost
// 0:text
AssertNode {
path: 0.into(),
expected: Some(placeholder_node()),
},
AssertNode {
path: 1.into(),
expected: Some(placeholder_node()),
},
AssertNode {
path: 2.into(),
expected: Some(placeholder_node()),
},
AssertNumberOfChildrenAtPath {
path: Some(3.into()),
expected: 4,
},
AssertNode {
path: path.into(),
expected: Some(text_node),
},
];
test.run_scripts(scripts);
}
@@ -1,170 +0,0 @@
use crate::node::script::NodeScript::*;
use crate::node::script::NodeTest;
use lib_ot::core::{NodeDataBuilder, NodeOperation, Path};
#[test]
fn operation_insert_op_transform_test() {
let node_1 = NodeDataBuilder::new("text_1").build();
let node_2 = NodeDataBuilder::new("text_2").build();
let op_1 = NodeOperation::Insert {
path: Path(vec![0, 1]),
nodes: vec![node_1],
};
let mut insert_2 = NodeOperation::Insert {
path: Path(vec![0, 1]),
nodes: vec![node_2],
};
// let mut node_tree = NodeTree::new("root");
// node_tree.apply_op(insert_1.clone()).unwrap();
op_1.transform(&mut insert_2);
let json = serde_json::to_string(&insert_2).unwrap();
assert_eq!(json, r#"{"op":"insert","path":[0,2],"nodes":[{"type":"text_2"}]}"#);
}
#[test]
fn operation_insert_one_level_path_test() {
let mut test = NodeTest::new();
let node_data_1 = NodeDataBuilder::new("text_1").build();
let node_data_2 = NodeDataBuilder::new("text_2").build();
let node_data_3 = NodeDataBuilder::new("text_3").build();
let node_3 = node_data_3.clone();
// 0: text_1
// 1: text_2
//
// Insert a new operation with rev_id 1,but the rev_id:1 is already exist, so
// it needs to be transformed.
// 1:text_3 => 2:text_3
//
// 0: text_1
// 1: text_2
// 2: text_3
//
// If the rev_id of the insert operation is 3. then the tree will be:
// 0: text_1
// 1: text_3
// 2: text_2
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: node_data_1,
rev_id: 1,
},
InsertNode {
path: 1.into(),
node_data: node_data_2,
rev_id: 2,
},
InsertNode {
path: 1.into(),
node_data: node_data_3,
rev_id: 1,
},
AssertNode {
path: 2.into(),
expected: Some(node_3),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_insert_with_multiple_level_path_test() {
let mut test = NodeTest::new();
let node_data_1 = NodeDataBuilder::new("text_1")
.add_node_data(NodeDataBuilder::new("text_1_1").build())
.add_node_data(NodeDataBuilder::new("text_1_2").build())
.build();
let node_data_2 = NodeDataBuilder::new("text_2")
.add_node_data(NodeDataBuilder::new("text_2_1").build())
.add_node_data(NodeDataBuilder::new("text_2_2").build())
.build();
let node_data_3 = NodeDataBuilder::new("text_3").build();
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: node_data_1,
rev_id: 1,
},
InsertNode {
path: 1.into(),
node_data: node_data_2,
rev_id: 2,
},
InsertNode {
path: 1.into(),
node_data: node_data_3.clone(),
rev_id: 1,
},
AssertNode {
path: 2.into(),
expected: Some(node_data_3),
},
];
test.run_scripts(scripts);
}
#[test]
fn operation_delete_test() {
let mut test = NodeTest::new();
let node_data_1 = NodeDataBuilder::new("text_1").build();
let node_data_2 = NodeDataBuilder::new("text_2").build();
let node_data_3 = NodeDataBuilder::new("text_3").build();
let node_3 = node_data_3.clone();
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: node_data_1,
rev_id: 1,
},
InsertNode {
path: 1.into(),
node_data: node_data_2,
rev_id: 2,
},
// The node's in the tree will be:
// 0: text_1
// 2: text_2
//
// The insert action is happened concurrently with the delete action, because they
// share the same rev_id. aka, 3. The delete action is want to delete the node at index 1,
// but it was moved to index 2.
InsertNode {
path: 1.into(),
node_data: node_data_3,
rev_id: 3,
},
// 0: text_1
// 1: text_3
// 2: text_2
//
// The path of the delete action will be transformed to a new path that point to the text_2.
// 1 -> 2
DeleteNode {
path: 1.into(),
rev_id: 3,
},
// After perform the delete action, the tree will be:
// 0: text_1
// 1: text_3
AssertNumberOfChildrenAtPath {
path: None,
expected: 2,
},
AssertNode {
path: 1.into(),
expected: Some(node_3),
},
AssertNode {
path: 2.into(),
expected: None,
},
];
test.run_scripts(scripts);
}
+71 -15
View File
@@ -1,5 +1,6 @@
#![allow(clippy::all)]
use lib_ot::core::{NodeTreeContext, Transaction};
use lib_ot::core::{NodeTreeContext, OperationTransform, Transaction};
use lib_ot::text_delta::DeltaTextOperationBuilder;
use lib_ot::{
core::attributes::AttributeHashMap,
core::{Body, Changeset, NodeData, NodeTree, Path, TransactionBuilder},
@@ -46,10 +47,18 @@ pub enum NodeScript {
path: Path,
expected: Option<NodeData>,
},
AssertNodeAttributes {
path: Path,
expected: &'static str,
},
AssertNodeDelta {
path: Path,
expected: DeltaTextOperations,
},
AssertNodeDeltaContent {
path: Path,
expected: &'static str,
},
#[allow(dead_code)]
AssertTreeJSON {
expected: String,
@@ -84,9 +93,7 @@ impl NodeTest {
node_data: node,
rev_id,
} => {
let mut transaction = TransactionBuilder::new(&self.node_tree)
.insert_node_at_path(path, node)
.finalize();
let mut transaction = TransactionBuilder::new().insert_node_at_path(path, node).build();
self.transform_transaction_if_need(&mut transaction, rev_id);
self.apply_transaction(transaction);
}
@@ -95,29 +102,34 @@ impl NodeTest {
node_data_list,
rev_id,
} => {
let mut transaction = TransactionBuilder::new(&self.node_tree)
let mut transaction = TransactionBuilder::new()
.insert_nodes_at_path(path, node_data_list)
.finalize();
.build();
self.transform_transaction_if_need(&mut transaction, rev_id);
self.apply_transaction(transaction);
}
NodeScript::UpdateAttributes { path, attributes } => {
let transaction = TransactionBuilder::new(&self.node_tree)
.update_attributes_at_path(&path, attributes)
.finalize();
let node = self.node_tree.get_node_data_at_path(&path).unwrap();
let transaction = TransactionBuilder::new()
.update_node_at_path(
&path,
Changeset::Attributes {
new: attributes,
old: node.attributes,
},
)
.build();
self.apply_transaction(transaction);
}
NodeScript::UpdateBody { path, changeset } => {
//
let transaction = TransactionBuilder::new(&self.node_tree)
.update_body_at_path(&path, changeset)
.finalize();
let transaction = TransactionBuilder::new().update_node_at_path(&path, changeset).build();
self.apply_transaction(transaction);
}
NodeScript::DeleteNode { path, rev_id } => {
let mut transaction = TransactionBuilder::new(&self.node_tree)
.delete_node_at_path(&path)
.finalize();
let mut transaction = TransactionBuilder::new()
.delete_node_at_path(&self.node_tree, &path)
.build();
self.transform_transaction_if_need(&mut transaction, rev_id);
self.apply_transaction(transaction);
}
@@ -126,6 +138,10 @@ impl NodeTest {
let node = self.node_tree.get_node_data_at_path(&path);
assert_eq!(node, expected.map(|e| e.into()));
}
NodeScript::AssertNodeAttributes { path, expected } => {
let node = self.node_tree.get_node_data_at_path(&path).unwrap();
assert_eq!(node.attributes.to_json().unwrap(), expected);
}
NodeScript::AssertNumberOfChildrenAtPath { path, expected } => match path {
None => {
let len = self.node_tree.number_of_children(None);
@@ -153,6 +169,14 @@ impl NodeTest {
panic!("Node body type not match, expect Delta");
}
}
NodeScript::AssertNodeDeltaContent { path, expected } => {
let node = self.node_tree.get_node_at_path(&path).unwrap();
if let Body::Delta(delta) = node.body.clone() {
debug_assert_eq!(delta.content().unwrap(), expected);
} else {
panic!("Node body type not match, expect Delta");
}
}
NodeScript::AssertTreeJSON { expected } => {
let json = serde_json::to_string(&self.node_tree).unwrap();
assert_eq!(json, expected)
@@ -175,3 +199,35 @@ impl NodeTest {
}
}
}
pub fn edit_node_delta(
delta: &DeltaTextOperations,
new_delta: DeltaTextOperations,
) -> (Changeset, DeltaTextOperations) {
let inverted = new_delta.invert(&delta);
let expected = delta.compose(&new_delta).unwrap();
let changeset = Changeset::Delta {
delta: new_delta.clone(),
inverted: inverted.clone(),
};
(changeset, expected)
}
pub fn make_node_delta_changeset(
initial_content: &str,
insert_str: &str,
) -> (DeltaTextOperations, Changeset, DeltaTextOperations) {
let initial_content = initial_content.to_owned();
let initial_delta = DeltaTextOperationBuilder::new().insert(&initial_content).build();
let delta = DeltaTextOperationBuilder::new()
.retain(initial_content.len())
.insert(insert_str)
.build();
let inverted = delta.invert(&initial_delta);
let expected = initial_delta.compose(&delta).unwrap();
let changeset = Changeset::Delta {
delta: delta.clone(),
inverted: inverted.clone(),
};
(initial_delta, changeset, expected)
}
+28 -30
View File
@@ -1,6 +1,4 @@
use lib_ot::core::{
AttributeBuilder, Changeset, NodeData, NodeDataBuilder, NodeOperation, NodeTree, Path, Transaction,
};
use lib_ot::core::{AttributeBuilder, Changeset, NodeData, NodeDataBuilder, NodeOperation, NodeTree, Path};
use lib_ot::text_delta::DeltaTextOperationBuilder;
#[test]
@@ -69,33 +67,33 @@ fn operation_update_node_body_deserialize_test() {
assert_eq!(json_1, json_2);
}
#[test]
fn transaction_serialize_test() {
let insert = NodeOperation::Insert {
path: Path(vec![0, 1]),
nodes: vec![NodeData::new("text".to_owned())],
};
let transaction = Transaction::from_operations(vec![insert]);
let json = serde_json::to_string(&transaction).unwrap();
assert_eq!(
json,
r#"{"operations":[{"op":"insert","path":[0,1],"nodes":[{"type":"text"}]}]}"#
);
}
#[test]
fn transaction_deserialize_test() {
let json = r#"{"operations":[{"op":"insert","path":[0,1],"nodes":[{"type":"text"}]}],"TextSelection":{"before_selection":{"start":{"path":[],"offset":0},"end":{"path":[],"offset":0}},"after_selection":{"start":{"path":[],"offset":0},"end":{"path":[],"offset":0}}}}"#;
let transaction: Transaction = serde_json::from_str(json).unwrap();
assert_eq!(transaction.operations.len(), 1);
}
#[test]
fn node_tree_deserialize_test() {
let tree: NodeTree = serde_json::from_str(TREE_JSON).unwrap();
assert_eq!(tree.number_of_children(None), 1);
}
// #[test]
// fn transaction_serialize_test() {
// let insert = NodeOperation::Insert {
// path: Path(vec![0, 1]),
// nodes: vec![NodeData::new("text".to_owned())],
// };
// let transaction = Transaction::from_operations(vec![insert]);
// let json = serde_json::to_string(&transaction).unwrap();
// assert_eq!(
// json,
// r#"{"operations":[{"op":"insert","path":[0,1],"nodes":[{"type":"text"}]}]}"#
// );
// }
//
// #[test]
// fn transaction_deserialize_test() {
// let json = r#"{"operations":[{"op":"insert","path":[0,1],"nodes":[{"type":"text"}]}],"TextSelection":{"before_selection":{"start":{"path":[],"offset":0},"end":{"path":[],"offset":0}},"after_selection":{"start":{"path":[],"offset":0},"end":{"path":[],"offset":0}}}}"#;
//
// let transaction: Transaction = serde_json::from_str(json).unwrap();
// assert_eq!(transaction.operations.len(), 1);
// }
//
// #[test]
// fn node_tree_deserialize_test() {
// let tree: NodeTree = serde_json::from_str(TREE_JSON).unwrap();
// assert_eq!(tree.number_of_children(None), 1);
// }
#[test]
fn node_tree_serialize_test() {
@@ -0,0 +1,104 @@
use crate::node::script::{edit_node_delta, make_node_delta_changeset};
use lib_ot::core::{AttributeEntry, Changeset, NodeDataBuilder, NodeOperation, Transaction, TransactionBuilder};
use lib_ot::text_delta::DeltaTextOperationBuilder;
#[test]
fn transaction_compose_update_after_insert_test() {
let (initial_delta, changeset, _) = make_node_delta_changeset("Hello", " world");
let node_data = NodeDataBuilder::new("text").insert_delta(initial_delta).build();
// Modify the same path, the operations will be merged after composing if possible.
let mut transaction_a = TransactionBuilder::new().insert_node_at_path(0, node_data).build();
let transaction_b = TransactionBuilder::new().update_node_at_path(0, changeset).build();
let _ = transaction_a.compose(transaction_b).unwrap();
// The operations are merged into one operation
assert_eq!(transaction_a.operations.len(), 1);
assert_eq!(
transaction_a.to_json().unwrap(),
r#"{"operations":[{"op":"insert","path":[0],"nodes":[{"type":"text","body":{"delta":[{"insert":"Hello world"}]}}]}]}"#
);
}
#[test]
fn transaction_compose_multiple_update_test() {
let (initial_delta, changeset_1, final_delta) = make_node_delta_changeset("Hello", " world");
let mut transaction = TransactionBuilder::new()
.insert_node_at_path(0, NodeDataBuilder::new("text").insert_delta(initial_delta).build())
.build();
let (changeset_2, _) = edit_node_delta(
&final_delta,
DeltaTextOperationBuilder::new()
.retain(final_delta.utf16_target_len)
.insert("😁")
.build(),
);
let mut other_transaction = Transaction::new();
// the following two update operations will be merged into one
let update_1 = TransactionBuilder::new().update_node_at_path(0, changeset_1).build();
other_transaction.compose(update_1).unwrap();
let update_2 = TransactionBuilder::new().update_node_at_path(0, changeset_2).build();
other_transaction.compose(update_2).unwrap();
let inverted = Transaction::from_operations(other_transaction.operations.inverted());
// the update operation will be merged into insert operation
let _ = transaction.compose(other_transaction).unwrap();
assert_eq!(transaction.operations.len(), 1);
assert_eq!(
transaction.to_json().unwrap(),
r#"{"operations":[{"op":"insert","path":[0],"nodes":[{"type":"text","body":{"delta":[{"insert":"Hello world😁"}]}}]}]}"#
);
let _ = transaction.compose(inverted).unwrap();
assert_eq!(
transaction.to_json().unwrap(),
r#"{"operations":[{"op":"insert","path":[0],"nodes":[{"type":"text","body":{"delta":[{"insert":"Hello"}]}}]}]}"#
);
}
#[test]
fn transaction_compose_multiple_attribute_test() {
let delta = DeltaTextOperationBuilder::new().insert("Hello").build();
let node = NodeDataBuilder::new("text").insert_delta(delta).build();
let insert_operation = NodeOperation::Insert {
path: 0.into(),
nodes: vec![node],
};
let mut transaction = Transaction::new();
transaction.push_operation(insert_operation);
let new_attribute = AttributeEntry::new("subtype", "bulleted-list");
let update_operation = NodeOperation::Update {
path: 0.into(),
changeset: Changeset::Attributes {
new: new_attribute.clone().into(),
old: Default::default(),
},
};
transaction.push_operation(update_operation);
assert_eq!(
transaction.to_json().unwrap(),
r#"{"operations":[{"op":"insert","path":[0],"nodes":[{"type":"text","body":{"delta":[{"insert":"Hello"}]}}]},{"op":"update","path":[0],"changeset":{"attributes":{"new":{"subtype":"bulleted-list"},"old":{}}}}]}"#
);
let old_attribute = new_attribute;
let new_attribute = AttributeEntry::new("subtype", "number-list");
transaction.push_operation(NodeOperation::Update {
path: 0.into(),
changeset: Changeset::Attributes {
new: new_attribute.into(),
old: old_attribute.into(),
},
});
assert_eq!(
transaction.to_json().unwrap(),
r#"{"operations":[{"op":"insert","path":[0],"nodes":[{"type":"text","body":{"delta":[{"insert":"Hello"}]}}]},{"op":"update","path":[0],"changeset":{"attributes":{"new":{"subtype":"number-list"},"old":{"subtype":"bulleted-list"}}}}]}"#
);
}
+19 -111
View File
@@ -1,10 +1,7 @@
use crate::node::script::NodeScript::*;
use crate::node::script::NodeTest;
use lib_ot::core::Body;
use lib_ot::core::Changeset;
use lib_ot::core::OperationTransform;
use crate::node::script::{make_node_delta_changeset, NodeTest};
use lib_ot::core::{NodeData, NodeDataBuilder, Path};
use lib_ot::text_delta::{DeltaTextOperationBuilder, DeltaTextOperations};
#[test]
fn node_insert_test() {
@@ -37,71 +34,6 @@ fn node_insert_with_empty_path_test() {
test.run_scripts(scripts);
}
#[test]
#[should_panic]
fn node_insert_with_not_exist_path_test() {
let mut test = NodeTest::new();
let node_data = NodeData::new("text");
let path: Path = vec![0, 0, 9].into();
let scripts = vec![
InsertNode {
path: path.clone(),
node_data: node_data.clone(),
rev_id: 1,
},
AssertNode {
path,
expected: Some(node_data),
},
];
test.run_scripts(scripts);
}
#[test]
// Append the node to the end of the list if the insert path is out of bounds.
fn node_insert_out_of_bound_test() {
let mut test = NodeTest::new();
let image_a = NodeData::new("image_a");
let image_b = NodeData::new("image_b");
let image = NodeDataBuilder::new("image_1")
.add_node_data(image_a)
.add_node_data(image_b)
.build();
let text_node = NodeDataBuilder::new("text_1").add_node_data(image).build();
let image_c = NodeData::new("image_c");
let scripts = vec![
InsertNode {
path: 0.into(),
node_data: text_node,
rev_id: 1,
},
// 0:text_1
// 0:image_1
// 0:image_a
// 1:image_b
InsertNode {
path: vec![0, 0, 10].into(),
node_data: image_c.clone(),
rev_id: 2,
},
// 0:text_1
// 0:image_1
// 0:image_a
// 1:image_b
// 2:image_b
AssertNode {
path: vec![0, 0, 2].into(),
expected: Some(image_c),
},
AssertNode {
path: vec![0, 0, 10].into(),
expected: None,
},
];
test.run_scripts(scripts);
}
#[test]
fn tree_insert_multiple_nodes_at_root_path_test() {
let mut test = NodeTest::new();
@@ -438,11 +370,11 @@ fn node_delete_node_from_list_test() {
InsertNode {
path: 1.into(),
node_data: text_node_2.clone(),
rev_id: 1,
rev_id: 2,
},
DeleteNode {
path: 0.into(),
rev_id: 2,
rev_id: 3,
},
AssertNode {
path: 1.into(),
@@ -487,7 +419,7 @@ fn node_delete_nested_node_test() {
InsertNode {
path: 1.into(),
node_data: text_node_2,
rev_id: 1,
rev_id: 2,
},
// 0:text_1
// 0:image_1
@@ -499,7 +431,7 @@ fn node_delete_nested_node_test() {
// 1:image_b
DeleteNode {
path: vec![0, 0, 0].into(),
rev_id: 2,
rev_id: 3,
},
// 0:text_1
// 0:image_1
@@ -514,7 +446,7 @@ fn node_delete_nested_node_test() {
},
DeleteNode {
path: vec![0, 0].into(),
rev_id: 3,
rev_id: 4,
},
// 0:text_1
// 1:text_2
@@ -676,12 +608,16 @@ fn node_reorder_nodes_test() {
// 1:image_b
DeleteNode {
path: vec![0].into(),
rev_id: 2,
rev_id: 3,
},
AssertNode {
path: vec![0].into(),
expected: Some(text_node_2.clone()),
},
InsertNode {
path: vec![1].into(),
node_data: text_node_1.clone(),
rev_id: 3,
rev_id: 4,
},
// 0:text_2
// 0:image_2
@@ -722,10 +658,8 @@ fn node_reorder_nodes_test() {
#[test]
fn node_update_body_test() {
let mut test = NodeTest::new();
let (initial_delta, changeset, _, expected) = make_node_delta_changeset("Hello", "AppFlowy");
let node = NodeDataBuilder::new("text")
.insert_body(Body::Delta(initial_delta))
.build();
let (initial_delta, changeset, expected) = make_node_delta_changeset("Hello", "AppFlowy");
let node = NodeDataBuilder::new("text").insert_delta(initial_delta).build();
let scripts = vec![
InsertNode {
@@ -748,10 +682,8 @@ fn node_update_body_test() {
#[test]
fn node_inverted_body_changeset_test() {
let mut test = NodeTest::new();
let (initial_delta, changeset, inverted_changeset, _expected) = make_node_delta_changeset("Hello", "AppFlowy");
let node = NodeDataBuilder::new("text")
.insert_body(Body::Delta(initial_delta.clone()))
.build();
let (initial_delta, changeset, _expected) = make_node_delta_changeset("Hello", "AppFlowy");
let node = NodeDataBuilder::new("text").insert_delta(initial_delta.clone()).build();
let scripts = vec![
InsertNode {
@@ -761,11 +693,11 @@ fn node_inverted_body_changeset_test() {
},
UpdateBody {
path: 0.into(),
changeset,
changeset: changeset.clone(),
},
UpdateBody {
path: 0.into(),
changeset: inverted_changeset,
changeset: changeset.inverted(),
},
AssertNodeDelta {
path: 0.into(),
@@ -774,27 +706,3 @@ fn node_inverted_body_changeset_test() {
];
test.run_scripts(scripts);
}
fn make_node_delta_changeset(
initial_content: &str,
insert_str: &str,
) -> (DeltaTextOperations, Changeset, Changeset, DeltaTextOperations) {
let initial_content = initial_content.to_owned();
let initial_delta = DeltaTextOperationBuilder::new().insert(&initial_content).build();
let delta = DeltaTextOperationBuilder::new()
.retain(initial_content.len())
.insert(insert_str)
.build();
let inverted = delta.invert(&initial_delta);
let expected = initial_delta.compose(&delta).unwrap();
let changeset = Changeset::Delta {
delta: delta.clone(),
inverted: inverted.clone(),
};
let inverted_changeset = Changeset::Delta {
delta: inverted,
inverted: delta,
};
(initial_delta, changeset, inverted_changeset, expected)
}