Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8afd6a6f1f | ||
|
|
0f8eb47602 | ||
|
|
badbbecf0d | ||
|
|
aec205394b | ||
|
|
d24625c053 | ||
|
|
b293150b19 | ||
|
|
3bec4caa53 | ||
|
|
d0d7529d28 | ||
|
|
6884e97146 | ||
|
|
9477f00831 | ||
|
|
e426aa5195 | ||
|
|
ceb309ac50 |
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/**"
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "release/*"
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
- "release/*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -9,12 +9,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "release/*"
|
||||
paths:
|
||||
- "frontend/app_flowy/**"
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
- "release/*"
|
||||
paths:
|
||||
- "frontend/app_flowy/**"
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "release/*"
|
||||
paths:
|
||||
- "frontend/app_flowy/**"
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
- "release/*"
|
||||
paths:
|
||||
- "frontend/app_flowy/**"
|
||||
|
||||
|
||||
@@ -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/**"
|
||||
|
||||
@@ -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/**"
|
||||
|
||||
@@ -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/**"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+4
-2
@@ -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'],
|
||||
|
||||
+17
-14
@@ -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),
|
||||
|
||||
+7
-4
@@ -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,
|
||||
);
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+10
-4
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
+12
-4
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
-50
@@ -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
|
||||
|
||||
+25
-12
@@ -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,
|
||||
),
|
||||
|
||||
+23
-13
@@ -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),
|
||||
|
||||
+5
-4
@@ -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) {
|
||||
|
||||
+18
-2
@@ -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,
|
||||
),
|
||||
|
||||
+24
-8
@@ -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) =>
|
||||
|
||||
+25
-7
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
+11
-8
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
+31
-13
@@ -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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
+40
-19
@@ -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,
|
||||
);
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+159
-214
@@ -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;
|
||||
}
|
||||
+2
-1
@@ -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);
|
||||
},
|
||||
|
||||
+1
@@ -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),
|
||||
|
||||
+36
-19
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+17
-4
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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(¶ms.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>
|
||||
|
||||
@@ -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::*;
|
||||
@@ -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;
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"}}}}]}"#
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user