[flutter]: add flutter_quill for test
52
app_flowy/ios/Podfile.lock
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
PODS:
|
||||||
|
- flowy_editor (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- flowy_infra_ui (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- flowy_sdk (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- Flutter (1.0.0)
|
||||||
|
- flutter_keyboard_visibility (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- path_provider (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- url_launcher (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
|
DEPENDENCIES:
|
||||||
|
- flowy_editor (from `.symlinks/plugins/flowy_editor/ios`)
|
||||||
|
- flowy_infra_ui (from `.symlinks/plugins/flowy_infra_ui/ios`)
|
||||||
|
- flowy_sdk (from `.symlinks/plugins/flowy_sdk/ios`)
|
||||||
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
|
||||||
|
- path_provider (from `.symlinks/plugins/path_provider/ios`)
|
||||||
|
- url_launcher (from `.symlinks/plugins/url_launcher/ios`)
|
||||||
|
|
||||||
|
EXTERNAL SOURCES:
|
||||||
|
flowy_editor:
|
||||||
|
:path: ".symlinks/plugins/flowy_editor/ios"
|
||||||
|
flowy_infra_ui:
|
||||||
|
:path: ".symlinks/plugins/flowy_infra_ui/ios"
|
||||||
|
flowy_sdk:
|
||||||
|
:path: ".symlinks/plugins/flowy_sdk/ios"
|
||||||
|
Flutter:
|
||||||
|
:path: Flutter
|
||||||
|
flutter_keyboard_visibility:
|
||||||
|
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
|
||||||
|
path_provider:
|
||||||
|
:path: ".symlinks/plugins/path_provider/ios"
|
||||||
|
url_launcher:
|
||||||
|
:path: ".symlinks/plugins/url_launcher/ios"
|
||||||
|
|
||||||
|
SPEC CHECKSUMS:
|
||||||
|
flowy_editor: bf8d58894ddb03453bd4d8521c57267ad638b837
|
||||||
|
flowy_infra_ui: 146c88346fd55d2ee6a41ae35059a5bf095cfbb3
|
||||||
|
flowy_sdk: c416222c639e678828776789bf0c1a1d0d59df3c
|
||||||
|
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
|
||||||
|
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
||||||
|
path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c
|
||||||
|
url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef
|
||||||
|
|
||||||
|
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c
|
||||||
|
|
||||||
|
COCOAPODS: 1.10.1
|
|
@ -13,6 +13,7 @@
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
9D1D47ADD7F5DE8237063BCA /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 197F72694BED43249F1523E8 /* Pods_Runner.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
@ -31,7 +32,11 @@
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
197F72694BED43249F1523E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
35DA03217F6DD4F7AC9356F9 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
4C2CB38DA64605A62D45B098 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
580A1ED8E012CA1552E5EFD3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
@ -49,12 +54,21 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
9D1D47ADD7F5DE8237063BCA /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
78844014EF958DCBB6F9B4EA /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
197F72694BED43249F1523E8 /* Pods_Runner.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -72,6 +86,8 @@
|
||||||
9740EEB11CF90186004384FC /* Flutter */,
|
9740EEB11CF90186004384FC /* Flutter */,
|
||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
|
9EC83BEE9154F1BD11D24F8F /* Pods */,
|
||||||
|
78844014EF958DCBB6F9B4EA /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
@ -98,6 +114,17 @@
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
9EC83BEE9154F1BD11D24F8F /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
35DA03217F6DD4F7AC9356F9 /* Pods-Runner.debug.xcconfig */,
|
||||||
|
580A1ED8E012CA1552E5EFD3 /* Pods-Runner.release.xcconfig */,
|
||||||
|
4C2CB38DA64605A62D45B098 /* Pods-Runner.profile.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Pods;
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
@ -105,12 +132,14 @@
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
E790B8FE5609053209ED85CB /* [CP] Check Pods Manifest.lock */,
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
97C146EC1CF9000F007C117D /* Resources */,
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
|
08FAA63113168DEC7FB74204 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
|
@ -169,6 +198,23 @@
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
08FAA63113168DEC7FB74204 /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
@ -197,6 +243,28 @@
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
};
|
};
|
||||||
|
E790B8FE5609053209ED85CB /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
|
|
@ -4,4 +4,7 @@
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "group:Runner.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flowy_editor/flowy_editor.dart';
|
|
||||||
import 'package:dartz/dartz.dart';
|
import 'package:dartz/dartz.dart';
|
||||||
// ignore: implementation_imports
|
// ignore: implementation_imports
|
||||||
import 'package:flowy_editor/src/model/quill_delta.dart';
|
import 'package:editor/flutter_quill.dart';
|
||||||
|
// import 'package:flowy_editor/flowy_editor.dart';
|
||||||
import 'package:flowy_log/flowy_log.dart';
|
import 'package:flowy_log/flowy_log.dart';
|
||||||
import 'package:flowy_sdk/protobuf/flowy-document/doc.pb.dart';
|
import 'package:flowy_sdk/protobuf/flowy-document/doc.pb.dart';
|
||||||
import 'package:flowy_sdk/protobuf/flowy-workspace/errors.pb.dart';
|
import 'package:flowy_sdk/protobuf/flowy-workspace/errors.pb.dart';
|
||||||
|
|
|
@ -3,19 +3,19 @@ import 'dart:io';
|
||||||
import 'package:app_flowy/startup/startup.dart';
|
import 'package:app_flowy/startup/startup.dart';
|
||||||
import 'package:app_flowy/workspace/application/doc/doc_edit_bloc.dart';
|
import 'package:app_flowy/workspace/application/doc/doc_edit_bloc.dart';
|
||||||
import 'package:app_flowy/workspace/domain/i_doc.dart';
|
import 'package:app_flowy/workspace/domain/i_doc.dart';
|
||||||
import 'package:flowy_editor/flowy_editor.dart';
|
// import 'package:flowy_editor/flowy_editor.dart';
|
||||||
|
import 'package:editor/flutter_quill.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
// ignore: must_be_immutable
|
|
||||||
class DocPage extends StatefulWidget {
|
class DocPage extends StatefulWidget {
|
||||||
late EditorController controller;
|
late QuillController controller;
|
||||||
late DocEditBloc editBloc;
|
late DocEditBloc editBloc;
|
||||||
final FlowyDoc doc;
|
final FlowyDoc doc;
|
||||||
|
|
||||||
DocPage({Key? key, required this.doc}) : super(key: key) {
|
DocPage({Key? key, required this.doc}) : super(key: key) {
|
||||||
editBloc = getIt<DocEditBloc>(param1: doc.id);
|
editBloc = getIt<DocEditBloc>(param1: doc.id);
|
||||||
controller = EditorController(
|
controller = QuillController(
|
||||||
document: doc.document,
|
document: doc.document,
|
||||||
selection: const TextSelection.collapsed(offset: 0),
|
selection: const TextSelection.collapsed(offset: 0),
|
||||||
);
|
);
|
||||||
|
@ -54,8 +54,8 @@ class _DocPageState extends State<DocPage> {
|
||||||
await widget.doc.close();
|
await widget.doc.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _renderEditor(EditorController controller) {
|
Widget _renderEditor(QuillController controller) {
|
||||||
final editor = FlowyEditor(
|
final editor = QuillEditor(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
|
@ -71,10 +71,9 @@ class _DocPageState extends State<DocPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _renderToolbar(EditorController controller) {
|
Widget _renderToolbar(QuillController controller) {
|
||||||
return FlowyToolbar.basic(
|
return QuillToolbar.basic(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
onImageSelectCallback: _onImageSelection,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,3 +81,81 @@ class _DocPageState extends State<DocPage> {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// import 'package:flowy_editor/flowy_editor.dart';
|
||||||
|
|
||||||
|
// ignore: must_be_immutable
|
||||||
|
// class DocPage extends StatefulWidget {
|
||||||
|
// late EditorController controller;
|
||||||
|
// late DocEditBloc editBloc;
|
||||||
|
// final FlowyDoc doc;
|
||||||
|
|
||||||
|
// DocPage({Key? key, required this.doc}) : super(key: key) {
|
||||||
|
// editBloc = getIt<DocEditBloc>(param1: doc.id);
|
||||||
|
// controller = EditorController(
|
||||||
|
// document: doc.document,
|
||||||
|
// selection: const TextSelection.collapsed(offset: 0),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @override
|
||||||
|
// State<DocPage> createState() => _DocPageState();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// class _DocPageState extends State<DocPage> {
|
||||||
|
// final FocusNode _focusNode = FocusNode();
|
||||||
|
|
||||||
|
// @override
|
||||||
|
// Widget build(BuildContext context) {
|
||||||
|
// return BlocProvider.value(
|
||||||
|
// value: widget.editBloc,
|
||||||
|
// child: BlocBuilder<DocEditBloc, DocEditState>(
|
||||||
|
// builder: (ctx, state) {
|
||||||
|
// return Column(
|
||||||
|
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
// children: [
|
||||||
|
// _renderEditor(widget.controller),
|
||||||
|
// _renderToolbar(widget.controller),
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @override
|
||||||
|
// Future<void> dispose() async {
|
||||||
|
// widget.editBloc.add(const DocEditEvent.close());
|
||||||
|
// widget.editBloc.close();
|
||||||
|
// super.dispose();
|
||||||
|
// await widget.doc.close();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Widget _renderEditor(EditorController controller) {
|
||||||
|
// final editor = FlowyEditor(
|
||||||
|
// controller: controller,
|
||||||
|
// focusNode: _focusNode,
|
||||||
|
// scrollable: true,
|
||||||
|
// autoFocus: false,
|
||||||
|
// expands: false,
|
||||||
|
// padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
// readOnly: false,
|
||||||
|
// scrollBottomInset: 0,
|
||||||
|
// scrollController: ScrollController(),
|
||||||
|
// );
|
||||||
|
// return Expanded(
|
||||||
|
// child: Padding(padding: const EdgeInsets.all(10), child: editor),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Widget _renderToolbar(EditorController controller) {
|
||||||
|
// return FlowyToolbar.basic(
|
||||||
|
// controller: controller,
|
||||||
|
// onImageSelectCallback: _onImageSelection,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Future<String> _onImageSelection(File file) {
|
||||||
|
// throw UnimplementedError();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import editor
|
||||||
import flowy_editor
|
import flowy_editor
|
||||||
import flowy_infra_ui
|
import flowy_infra_ui
|
||||||
import flowy_sdk
|
import flowy_sdk
|
||||||
|
@ -13,6 +14,7 @@ import url_launcher_macos
|
||||||
import window_size
|
import window_size
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
EditorPlugin.register(with: registry.registrar(forPlugin: "EditorPlugin"))
|
||||||
FlowyEditorPlugin.register(with: registry.registrar(forPlugin: "FlowyEditorPlugin"))
|
FlowyEditorPlugin.register(with: registry.registrar(forPlugin: "FlowyEditorPlugin"))
|
||||||
FlowyInfraUIPlugin.register(with: registry.registrar(forPlugin: "FlowyInfraUIPlugin"))
|
FlowyInfraUIPlugin.register(with: registry.registrar(forPlugin: "FlowyInfraUIPlugin"))
|
||||||
FlowySdkPlugin.register(with: registry.registrar(forPlugin: "FlowySdkPlugin"))
|
FlowySdkPlugin.register(with: registry.registrar(forPlugin: "FlowySdkPlugin"))
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
PODS:
|
PODS:
|
||||||
|
- editor (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- flowy_editor (0.0.1):
|
- flowy_editor (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- flowy_infra_ui (0.0.1):
|
- flowy_infra_ui (0.0.1):
|
||||||
|
@ -14,6 +16,7 @@ PODS:
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- editor (from `Flutter/ephemeral/.symlinks/plugins/editor/macos`)
|
||||||
- flowy_editor (from `Flutter/ephemeral/.symlinks/plugins/flowy_editor/macos`)
|
- flowy_editor (from `Flutter/ephemeral/.symlinks/plugins/flowy_editor/macos`)
|
||||||
- flowy_infra_ui (from `Flutter/ephemeral/.symlinks/plugins/flowy_infra_ui/macos`)
|
- flowy_infra_ui (from `Flutter/ephemeral/.symlinks/plugins/flowy_infra_ui/macos`)
|
||||||
- flowy_sdk (from `Flutter/ephemeral/.symlinks/plugins/flowy_sdk/macos`)
|
- flowy_sdk (from `Flutter/ephemeral/.symlinks/plugins/flowy_sdk/macos`)
|
||||||
|
@ -23,6 +26,8 @@ DEPENDENCIES:
|
||||||
- window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`)
|
- window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
editor:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/editor/macos
|
||||||
flowy_editor:
|
flowy_editor:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/flowy_editor/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/flowy_editor/macos
|
||||||
flowy_infra_ui:
|
flowy_infra_ui:
|
||||||
|
@ -39,6 +44,7 @@ EXTERNAL SOURCES:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/window_size/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/window_size/macos
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
editor: 380351c0334fbeb0e431e4e49629c9e2d925b66d
|
||||||
flowy_editor: 26060a984848e6afac1f6a4455511f4114119d8d
|
flowy_editor: 26060a984848e6afac1f6a4455511f4114119d8d
|
||||||
flowy_infra_ui: 9d5021b1610fe0476eb1191bf7cd41c4a4138d8f
|
flowy_infra_ui: 9d5021b1610fe0476eb1191bf7cd41c4a4138d8f
|
||||||
flowy_sdk: c302ac0a22dea596db0df8073b9637b2bf2ff6fd
|
flowy_sdk: c302ac0a22dea596db0df8073b9637b2bf2ff6fd
|
||||||
|
|
29
app_flowy/packages/editor/.gitignore
vendored
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||||
|
/pubspec.lock
|
||||||
|
**/doc/api/
|
||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
build/
|
10
app_flowy/packages/editor/.metadata
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: 4b330ddbedab445481cc73d50a4695b9154b4e4f
|
||||||
|
channel: dev
|
||||||
|
|
||||||
|
project_type: plugin
|
3
app_flowy/packages/editor/CHANGELOG.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
## 0.0.1
|
||||||
|
|
||||||
|
* TODO: Describe initial release.
|
1
app_flowy/packages/editor/LICENSE
Normal file
|
@ -0,0 +1 @@
|
||||||
|
TODO: Add your license here.
|
15
app_flowy/packages/editor/README.md
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# editor
|
||||||
|
|
||||||
|
A new flutter plugin project.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter
|
||||||
|
[plug-in package](https://flutter.dev/developing-packages/),
|
||||||
|
a specialized package that includes platform-specific implementation code for
|
||||||
|
Android and/or iOS.
|
||||||
|
|
||||||
|
For help getting started with Flutter, view our
|
||||||
|
[online documentation](https://flutter.dev/docs), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
|
|
4
app_flowy/packages/editor/analysis_options.yaml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
46
app_flowy/packages/editor/example/.gitignore
vendored
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# Web related
|
||||||
|
lib/generated_plugin_registrant.dart
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
10
app_flowy/packages/editor/example/.metadata
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: 4b330ddbedab445481cc73d50a4695b9154b4e4f
|
||||||
|
channel: dev
|
||||||
|
|
||||||
|
project_type: app
|
16
app_flowy/packages/editor/example/README.md
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# editor_example
|
||||||
|
|
||||||
|
Demonstrates how to use the editor plugin.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab)
|
||||||
|
- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook)
|
||||||
|
|
||||||
|
For help getting started with Flutter, view our
|
||||||
|
[online documentation](https://flutter.dev/docs), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
29
app_flowy/packages/editor/example/analysis_options.yaml
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
# The lint rules applied to this project can be customized in the
|
||||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
# included above or to enable additional rules. A list of all available lints
|
||||||
|
# and their documentation is published at
|
||||||
|
# https://dart-lang.github.io/linter/lints/index.html.
|
||||||
|
#
|
||||||
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
|
# section below, it can also be suppressed for a single line of code
|
||||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
38
app_flowy/packages/editor/example/lib/main.dart
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
runApp(const MyApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyApp extends StatefulWidget {
|
||||||
|
const MyApp({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyApp> createState() => _MyAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyAppState extends State<MyApp> {
|
||||||
|
String _platformVersion = 'Unknown';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Plugin example app'),
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: Text('Running on: $_platformVersion\n'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
7
app_flowy/packages/editor/example/macos/.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# Flutter-related
|
||||||
|
**/Flutter/ephemeral/
|
||||||
|
**/Pods/
|
||||||
|
|
||||||
|
# Xcode-related
|
||||||
|
**/dgph
|
||||||
|
**/xcuserdata/
|
|
@ -0,0 +1,2 @@
|
||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
|
#include "ephemeral/Flutter-Generated.xcconfig"
|
|
@ -0,0 +1,2 @@
|
||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
|
#include "ephemeral/Flutter-Generated.xcconfig"
|
|
@ -0,0 +1,14 @@
|
||||||
|
//
|
||||||
|
// Generated file. Do not edit.
|
||||||
|
//
|
||||||
|
|
||||||
|
import FlutterMacOS
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
import editor
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
EditorPlugin.register(with: registry.registrar(forPlugin: "EditorPlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
}
|
40
app_flowy/packages/editor/example/macos/Podfile
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
platform :osx, '10.11'
|
||||||
|
|
||||||
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|
||||||
|
project 'Runner', {
|
||||||
|
'Debug' => :debug,
|
||||||
|
'Profile' => :release,
|
||||||
|
'Release' => :release,
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutter_root
|
||||||
|
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
|
||||||
|
unless File.exist?(generated_xcode_build_settings_path)
|
||||||
|
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
|
||||||
|
end
|
||||||
|
|
||||||
|
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||||
|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||||
|
return matches[1].strip if matches
|
||||||
|
end
|
||||||
|
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
|
||||||
|
end
|
||||||
|
|
||||||
|
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||||
|
|
||||||
|
flutter_macos_podfile_setup
|
||||||
|
|
||||||
|
target 'Runner' do
|
||||||
|
use_frameworks!
|
||||||
|
use_modular_headers!
|
||||||
|
|
||||||
|
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
|
||||||
|
end
|
||||||
|
|
||||||
|
post_install do |installer|
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
flutter_additional_macos_build_settings(target)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,572 @@
|
||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 51;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXAggregateTarget section */
|
||||||
|
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
|
||||||
|
isa = PBXAggregateTarget;
|
||||||
|
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
|
||||||
|
buildPhases = (
|
||||||
|
33CC111E2044C6BF0003C045 /* ShellScript */,
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = "Flutter Assemble";
|
||||||
|
productName = FLX;
|
||||||
|
};
|
||||||
|
/* End PBXAggregateTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
|
||||||
|
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
||||||
|
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||||
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||||
|
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
|
||||||
|
remoteInfo = FLX;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
33CC110E2044A8840003C045 /* Bundle Framework */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 10;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
name = "Bundle Framework";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||||
|
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||||
|
33CC10ED2044A3C60003C045 /* editor_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "editor_example.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||||
|
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
|
||||||
|
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
|
||||||
|
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||||
|
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||||
|
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
33CC10EA2044A3C60003C045 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
33BA886A226E78AF003329D5 /* Configs */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
|
||||||
|
);
|
||||||
|
path = Configs;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33CC10E42044A3C60003C045 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
33FAB671232836740065AC1E /* Runner */,
|
||||||
|
33CEB47122A05771004F2AC0 /* Flutter */,
|
||||||
|
33CC10EE2044A3C60003C045 /* Products */,
|
||||||
|
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33CC10EE2044A3C60003C045 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
33CC10ED2044A3C60003C045 /* editor_example.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33CC11242044D66E0003C045 /* Resources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
33CC10F22044A3C60003C045 /* Assets.xcassets */,
|
||||||
|
33CC10F42044A3C60003C045 /* MainMenu.xib */,
|
||||||
|
33CC10F72044A3C60003C045 /* Info.plist */,
|
||||||
|
);
|
||||||
|
name = Resources;
|
||||||
|
path = ..;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
||||||
|
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
||||||
|
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
||||||
|
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
|
||||||
|
);
|
||||||
|
path = Flutter;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
33FAB671232836740065AC1E /* Runner */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||||
|
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||||
|
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
||||||
|
33E51914231749380026EE4D /* Release.entitlements */,
|
||||||
|
33CC11242044D66E0003C045 /* Resources */,
|
||||||
|
33BA886A226E78AF003329D5 /* Configs */,
|
||||||
|
);
|
||||||
|
path = Runner;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
33CC10EC2044A3C60003C045 /* Runner */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
|
buildPhases = (
|
||||||
|
33CC10E92044A3C60003C045 /* Sources */,
|
||||||
|
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||||
|
33CC10EB2044A3C60003C045 /* Resources */,
|
||||||
|
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||||
|
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
33CC11202044C79F0003C045 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = Runner;
|
||||||
|
productName = Runner;
|
||||||
|
productReference = 33CC10ED2044A3C60003C045 /* editor_example.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
33CC10E52044A3C60003C045 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
LastSwiftUpdateCheck = 0920;
|
||||||
|
LastUpgradeCheck = 1300;
|
||||||
|
ORGANIZATIONNAME = "";
|
||||||
|
TargetAttributes = {
|
||||||
|
33CC10EC2044A3C60003C045 = {
|
||||||
|
CreatedOnToolsVersion = 9.2;
|
||||||
|
LastSwiftMigration = 1100;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
SystemCapabilities = {
|
||||||
|
com.apple.Sandbox = {
|
||||||
|
enabled = 1;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
33CC111A2044C6BA0003C045 = {
|
||||||
|
CreatedOnToolsVersion = 9.2;
|
||||||
|
ProvisioningStyle = Manual;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
|
||||||
|
compatibilityVersion = "Xcode 9.3";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 33CC10E42044A3C60003C045;
|
||||||
|
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
33CC10EC2044A3C60003C045 /* Runner */,
|
||||||
|
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
33CC10EB2044A3C60003C045 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
|
||||||
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
3399D490228B24CF009A79C7 /* ShellScript */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
|
||||||
|
};
|
||||||
|
33CC111E2044C6BF0003C045 /* ShellScript */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
Flutter/ephemeral/FlutterInputs.xcfilelist,
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
Flutter/ephemeral/tripwire,
|
||||||
|
);
|
||||||
|
outputFileListPaths = (
|
||||||
|
Flutter/ephemeral/FlutterOutputs.xcfilelist,
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
33CC10E92044A3C60003C045 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
|
||||||
|
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
||||||
|
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
|
||||||
|
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
33CC10F52044A3C60003C045 /* Base */,
|
||||||
|
);
|
||||||
|
name = MainMenu.xib;
|
||||||
|
path = Runner;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
338D0CE9231458BD00FA5F75 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "-";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 10.11;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
338D0CEA231458BD00FA5F75 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
338D0CEB231458BD00FA5F75 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
33CC10F92044A3C60003C045 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "-";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 10.11;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
33CC10FA2044A3C60003C045 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "-";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 10.11;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
33CC10FC2044A3C60003C045 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
33CC10FD2044A3C60003C045 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
33CC111C2044C6BA0003C045 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
33CC111D2044C6BA0003C045 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
33CC10F92044A3C60003C045 /* Debug */,
|
||||||
|
33CC10FA2044A3C60003C045 /* Release */,
|
||||||
|
338D0CE9231458BD00FA5F75 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
33CC10FC2044A3C60003C045 /* Debug */,
|
||||||
|
33CC10FD2044A3C60003C045 /* Release */,
|
||||||
|
338D0CEA231458BD00FA5F75 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
33CC111C2044C6BA0003C045 /* Debug */,
|
||||||
|
33CC111D2044C6BA0003C045 /* Release */,
|
||||||
|
338D0CEB231458BD00FA5F75 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1300"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
|
BuildableName = "editor_example.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
|
BuildableName = "editor_example.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
|
BuildableName = "editor_example.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Profile"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
|
BuildableName = "editor_example.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
7
app_flowy/packages/editor/example/macos/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Cocoa
|
||||||
|
import FlutterMacOS
|
||||||
|
|
||||||
|
@NSApplicationMain
|
||||||
|
class AppDelegate: FlutterAppDelegate {
|
||||||
|
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"size" : "16x16",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "app_icon_16.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "16x16",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "app_icon_32.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "32x32",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "app_icon_32.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "32x32",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "app_icon_64.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "128x128",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "app_icon_128.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "128x128",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "app_icon_256.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "256x256",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "app_icon_256.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "256x256",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "app_icon_512.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "512x512",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "app_icon_512.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "512x512",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"filename" : "app_icon_1024.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
After Width: | Height: | Size: 46 KiB |
After Width: | Height: | Size: 3.2 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 5.8 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 1.8 KiB |
|
@ -0,0 +1,339 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="macosx"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14490.70"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<objects>
|
||||||
|
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
|
||||||
|
<connections>
|
||||||
|
<outlet property="delegate" destination="Voe-Tx-rLC" id="GzC-gU-4Uq"/>
|
||||||
|
</connections>
|
||||||
|
</customObject>
|
||||||
|
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||||
|
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||||
|
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="Runner" customModuleProvider="target">
|
||||||
|
<connections>
|
||||||
|
<outlet property="applicationMenu" destination="uQy-DD-JDr" id="XBo-yE-nKs"/>
|
||||||
|
<outlet property="mainFlutterWindow" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
|
||||||
|
</connections>
|
||||||
|
</customObject>
|
||||||
|
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
|
||||||
|
<menu title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
|
||||||
|
<items>
|
||||||
|
<menuItem title="APP_NAME" id="1Xt-HY-uBw">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="APP_NAME" systemMenu="apple" id="uQy-DD-JDr">
|
||||||
|
<items>
|
||||||
|
<menuItem title="About APP_NAME" id="5kV-Vb-QxS">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="orderFrontStandardAboutPanel:" target="-1" id="Exp-CZ-Vem"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
|
||||||
|
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
|
||||||
|
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
|
||||||
|
<menuItem title="Services" id="NMo-om-nkz">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
|
||||||
|
<menuItem title="Hide APP_NAME" keyEquivalent="h" id="Olw-nP-bQN">
|
||||||
|
<connections>
|
||||||
|
<action selector="hide:" target="-1" id="PnN-Uc-m68"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="hideOtherApplications:" target="-1" id="VT4-aY-XCT"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Show All" id="Kd2-mp-pUS">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="unhideAllApplications:" target="-1" id="Dhg-Le-xox"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
|
||||||
|
<menuItem title="Quit APP_NAME" keyEquivalent="q" id="4sb-4s-VLi">
|
||||||
|
<connections>
|
||||||
|
<action selector="terminate:" target="-1" id="Te7-pn-YzF"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Edit" id="5QF-Oa-p0T">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Edit" id="W48-6f-4Dl">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Undo" keyEquivalent="z" id="dRJ-4n-Yzg">
|
||||||
|
<connections>
|
||||||
|
<action selector="undo:" target="-1" id="M6e-cu-g7V"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Redo" keyEquivalent="Z" id="6dh-zS-Vam">
|
||||||
|
<connections>
|
||||||
|
<action selector="redo:" target="-1" id="oIA-Rs-6OD"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="WRV-NI-Exz"/>
|
||||||
|
<menuItem title="Cut" keyEquivalent="x" id="uRl-iY-unG">
|
||||||
|
<connections>
|
||||||
|
<action selector="cut:" target="-1" id="YJe-68-I9s"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Copy" keyEquivalent="c" id="x3v-GG-iWU">
|
||||||
|
<connections>
|
||||||
|
<action selector="copy:" target="-1" id="G1f-GL-Joy"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Paste" keyEquivalent="v" id="gVA-U4-sdL">
|
||||||
|
<connections>
|
||||||
|
<action selector="paste:" target="-1" id="UvS-8e-Qdg"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Paste and Match Style" keyEquivalent="V" id="WeT-3V-zwk">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="pasteAsPlainText:" target="-1" id="cEh-KX-wJQ"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Delete" id="pa3-QI-u2k">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="delete:" target="-1" id="0Mk-Ml-PaM"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Select All" keyEquivalent="a" id="Ruw-6m-B2m">
|
||||||
|
<connections>
|
||||||
|
<action selector="selectAll:" target="-1" id="VNm-Mi-diN"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="uyl-h8-XO2"/>
|
||||||
|
<menuItem title="Find" id="4EN-yA-p0u">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Find" id="1b7-l0-nxx">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Find…" tag="1" keyEquivalent="f" id="Xz5-n4-O0W">
|
||||||
|
<connections>
|
||||||
|
<action selector="performFindPanelAction:" target="-1" id="cD7-Qs-BN4"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="YEy-JH-Tfz">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="performFindPanelAction:" target="-1" id="WD3-Gg-5AJ"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Find Next" tag="2" keyEquivalent="g" id="q09-fT-Sye">
|
||||||
|
<connections>
|
||||||
|
<action selector="performFindPanelAction:" target="-1" id="NDo-RZ-v9R"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Find Previous" tag="3" keyEquivalent="G" id="OwM-mh-QMV">
|
||||||
|
<connections>
|
||||||
|
<action selector="performFindPanelAction:" target="-1" id="HOh-sY-3ay"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="buJ-ug-pKt">
|
||||||
|
<connections>
|
||||||
|
<action selector="performFindPanelAction:" target="-1" id="U76-nv-p5D"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Jump to Selection" keyEquivalent="j" id="S0p-oC-mLd">
|
||||||
|
<connections>
|
||||||
|
<action selector="centerSelectionInVisibleArea:" target="-1" id="IOG-6D-g5B"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Spelling and Grammar" id="Dv1-io-Yv7">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Spelling" id="3IN-sU-3Bg">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="HFo-cy-zxI">
|
||||||
|
<connections>
|
||||||
|
<action selector="showGuessPanel:" target="-1" id="vFj-Ks-hy3"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Check Document Now" keyEquivalent=";" id="hz2-CU-CR7">
|
||||||
|
<connections>
|
||||||
|
<action selector="checkSpelling:" target="-1" id="fz7-VC-reM"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="bNw-od-mp5"/>
|
||||||
|
<menuItem title="Check Spelling While Typing" id="rbD-Rh-wIN">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleContinuousSpellChecking:" target="-1" id="7w6-Qz-0kB"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Check Grammar With Spelling" id="mK6-2p-4JG">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleGrammarChecking:" target="-1" id="muD-Qn-j4w"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Correct Spelling Automatically" id="78Y-hA-62v">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticSpellingCorrection:" target="-1" id="2lM-Qi-WAP"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Substitutions" id="9ic-FL-obx">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Substitutions" id="FeM-D8-WVr">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Show Substitutions" id="z6F-FW-3nz">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="orderFrontSubstitutionsPanel:" target="-1" id="oku-mr-iSq"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="gPx-C9-uUO"/>
|
||||||
|
<menuItem title="Smart Copy/Paste" id="9yt-4B-nSM">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleSmartInsertDelete:" target="-1" id="3IJ-Se-DZD"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Smart Quotes" id="hQb-2v-fYv">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticQuoteSubstitution:" target="-1" id="ptq-xd-QOA"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Smart Dashes" id="rgM-f4-ycn">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticDashSubstitution:" target="-1" id="oCt-pO-9gS"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Smart Links" id="cwL-P1-jid">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticLinkDetection:" target="-1" id="Gip-E3-Fov"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Data Detectors" id="tRr-pd-1PS">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticDataDetection:" target="-1" id="R1I-Nq-Kbl"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Text Replacement" id="HFQ-gK-NFA">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleAutomaticTextReplacement:" target="-1" id="DvP-Fe-Py6"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Transformations" id="2oI-Rn-ZJC">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Transformations" id="c8a-y6-VQd">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Make Upper Case" id="vmV-6d-7jI">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="uppercaseWord:" target="-1" id="sPh-Tk-edu"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Make Lower Case" id="d9M-CD-aMd">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="lowercaseWord:" target="-1" id="iUZ-b5-hil"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Capitalize" id="UEZ-Bs-lqG">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="capitalizeWord:" target="-1" id="26H-TL-nsh"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Speech" id="xrE-MZ-jX0">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Speech" id="3rS-ZA-NoH">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Start Speaking" id="Ynk-f8-cLZ">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="startSpeaking:" target="-1" id="654-Ng-kyl"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Stop Speaking" id="Oyz-dy-DGm">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="stopSpeaking:" target="-1" id="dX8-6p-jy9"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="View" id="H8h-7b-M4v">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="View" id="HyV-fh-RgO">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Enter Full Screen" keyEquivalent="f" id="4J7-dP-txa">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="toggleFullScreen:" target="-1" id="dU3-MA-1Rq"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Window" id="aUF-d1-5bR">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
|
||||||
|
<items>
|
||||||
|
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
|
||||||
|
<connections>
|
||||||
|
<action selector="performMiniaturize:" target="-1" id="VwT-WD-YPe"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem title="Zoom" id="R4o-n2-Eq4">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="performZoom:" target="-1" id="DIl-cC-cCs"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
|
||||||
|
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
|
||||||
|
<modifierMask key="keyEquivalentModifierMask"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="arrangeInFront:" target="-1" id="DRN-fu-gQh"/>
|
||||||
|
</connections>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
</menu>
|
||||||
|
</menuItem>
|
||||||
|
</items>
|
||||||
|
<point key="canvasLocation" x="142" y="-258"/>
|
||||||
|
</menu>
|
||||||
|
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
|
||||||
|
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||||
|
<rect key="contentRect" x="335" y="390" width="800" height="600"/>
|
||||||
|
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
|
||||||
|
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
</view>
|
||||||
|
</window>
|
||||||
|
</objects>
|
||||||
|
</document>
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Application-level settings for the Runner target.
|
||||||
|
//
|
||||||
|
// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the
|
||||||
|
// future. If not, the values below would default to using the project name when this becomes a
|
||||||
|
// 'flutter create' template.
|
||||||
|
|
||||||
|
// The application's name. By default this is also the title of the Flutter window.
|
||||||
|
PRODUCT_NAME = editor_example
|
||||||
|
|
||||||
|
// The application's bundle identifier
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.editorExample
|
||||||
|
|
||||||
|
// The copyright displayed in application information
|
||||||
|
PRODUCT_COPYRIGHT = Copyright © 2021 com.example. All rights reserved.
|
|
@ -0,0 +1,2 @@
|
||||||
|
#include "../../Flutter/Flutter-Debug.xcconfig"
|
||||||
|
#include "Warnings.xcconfig"
|
|
@ -0,0 +1,2 @@
|
||||||
|
#include "../../Flutter/Flutter-Release.xcconfig"
|
||||||
|
#include "Warnings.xcconfig"
|
|
@ -0,0 +1,13 @@
|
||||||
|
WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES
|
||||||
|
CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES
|
||||||
|
CLANG_WARN_PRAGMA_PACK = YES
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES
|
||||||
|
CLANG_WARN_COMMA = YES
|
||||||
|
GCC_WARN_STRICT_SELECTOR_MATCH = YES
|
||||||
|
CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES
|
||||||
|
GCC_WARN_SHADOW = YES
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
32
app_flowy/packages/editor/example/macos/Runner/Info.plist
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string></string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>$(PRODUCT_COPYRIGHT)</string>
|
||||||
|
<key>NSMainNibFile</key>
|
||||||
|
<string>MainMenu</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,15 @@
|
||||||
|
import Cocoa
|
||||||
|
import FlutterMacOS
|
||||||
|
|
||||||
|
class MainFlutterWindow: NSWindow {
|
||||||
|
override func awakeFromNib() {
|
||||||
|
let flutterViewController = FlutterViewController.init()
|
||||||
|
let windowFrame = self.frame
|
||||||
|
self.contentViewController = flutterViewController
|
||||||
|
self.setFrame(windowFrame, display: true)
|
||||||
|
|
||||||
|
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||||
|
|
||||||
|
super.awakeFromNib()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
404
app_flowy/packages/editor/example/pubspec.lock
Normal file
|
@ -0,0 +1,404 @@
|
||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.8.2"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
|
charcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: charcode
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.1"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.15.0"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1+5"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.1"
|
||||||
|
cupertino_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cupertino_icons
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
|
diff_match_patch:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: diff_match_patch
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.1"
|
||||||
|
editor:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: ".."
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.1"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_colorpicker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_colorpicker
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.0"
|
||||||
|
flutter_inappwebview:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "5.3.2"
|
||||||
|
flutter_keyboard_visibility:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_keyboard_visibility
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.3"
|
||||||
|
flutter_keyboard_visibility_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_keyboard_visibility_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
|
flutter_keyboard_visibility_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_keyboard_visibility_web
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.4"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
|
flutter_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.0"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.4"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
|
image_picker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.4+2"
|
||||||
|
image_picker_for_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_for_web
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
image_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.3"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.11"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.7.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.8.0"
|
||||||
|
pedantic:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pedantic
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.11.1"
|
||||||
|
photo_view:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: photo_view
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.0"
|
||||||
|
plugin_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: plugin_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
|
quiver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: quiver
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.99"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.8.1"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.0"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
string_validator:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_validator
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.0"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.3"
|
||||||
|
tuple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: tuple
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
url_launcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.12"
|
||||||
|
url_launcher_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_linux
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
|
url_launcher_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_macos
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
|
url_launcher_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
url_launcher_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_web
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
url_launcher_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_windows
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
video_player:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.5"
|
||||||
|
video_player_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.0"
|
||||||
|
video_player_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_web
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
youtube_player_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: youtube_player_flutter
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.0"
|
||||||
|
sdks:
|
||||||
|
dart: ">=2.15.0-116.0.dev <3.0.0"
|
||||||
|
flutter: ">=2.5.0"
|
84
app_flowy/packages/editor/example/pubspec.yaml
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
name: editor_example
|
||||||
|
description: Demonstrates how to use the editor plugin.
|
||||||
|
|
||||||
|
# The following line prevents the package from being accidentally published to
|
||||||
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ">=2.15.0-116.0.dev <3.0.0"
|
||||||
|
|
||||||
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
|
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||||
|
# dependencies can be manually updated by changing the version numbers below to
|
||||||
|
# the latest version available on pub.dev. To see which dependencies have newer
|
||||||
|
# versions available, run `flutter pub outdated`.
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
editor:
|
||||||
|
# When depending on this package from a real application you should use:
|
||||||
|
# editor: ^x.y.z
|
||||||
|
# See https://dart.dev/tools/pub/dependencies#version-constraints
|
||||||
|
# The example app is bundled with the plugin so we use a path dependency on
|
||||||
|
# the parent directory to use the current plugin's version.
|
||||||
|
path: ../
|
||||||
|
|
||||||
|
# The following adds the Cupertino Icons font to your application.
|
||||||
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
|
cupertino_icons: ^1.0.2
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
|
# package. See that file for information about deactivating specific lint
|
||||||
|
# rules and activating additional ones.
|
||||||
|
flutter_lints: ^1.0.0
|
||||||
|
|
||||||
|
# For information on the generic Dart part of this file, see the
|
||||||
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
# The following section is specific to Flutter.
|
||||||
|
flutter:
|
||||||
|
|
||||||
|
# The following line ensures that the Material Icons font is
|
||||||
|
# included with your application, so that you can use the icons in
|
||||||
|
# the material Icons class.
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
# To add assets to your application, add an assets section, like this:
|
||||||
|
# assets:
|
||||||
|
# - images/a_dot_burr.jpeg
|
||||||
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
# https://flutter.dev/assets-and-images/#resolution-aware.
|
||||||
|
|
||||||
|
# For details regarding adding assets from package dependencies, see
|
||||||
|
# https://flutter.dev/assets-and-images/#from-packages
|
||||||
|
|
||||||
|
# To add custom fonts to your application, add a fonts section here,
|
||||||
|
# in this "flutter" section. Each entry in this list should have a
|
||||||
|
# "family" key with the font family name, and a "fonts" key with a
|
||||||
|
# list giving the asset and other descriptors for the font. For
|
||||||
|
# example:
|
||||||
|
# fonts:
|
||||||
|
# - family: Schyler
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/Schyler-Regular.ttf
|
||||||
|
# - asset: fonts/Schyler-Italic.ttf
|
||||||
|
# style: italic
|
||||||
|
# - family: Trajan Pro
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/TrajanPro.ttf
|
||||||
|
# - asset: fonts/TrajanPro_Bold.ttf
|
||||||
|
# weight: 700
|
||||||
|
#
|
||||||
|
# For details regarding fonts from package dependencies,
|
||||||
|
# see https://flutter.dev/custom-fonts/#from-packages
|
11
app_flowy/packages/editor/example/test/widget_test.dart
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
// This is a basic Flutter widget test.
|
||||||
|
//
|
||||||
|
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||||
|
// utility that Flutter provides. For example, you can send tap and scroll
|
||||||
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
|
// import 'package:flutter/material.dart';
|
||||||
|
// import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {}
|
11
app_flowy/packages/editor/lib/flutter_quill.dart
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
library flutter_quill;
|
||||||
|
|
||||||
|
export 'src/models/documents/attribute.dart';
|
||||||
|
export 'src/models/documents/document.dart';
|
||||||
|
export 'src/models/documents/nodes/embed.dart';
|
||||||
|
export 'src/models/documents/nodes/leaf.dart';
|
||||||
|
export 'src/models/quill_delta.dart';
|
||||||
|
export 'src/widgets/controller.dart';
|
||||||
|
export 'src/widgets/default_styles.dart';
|
||||||
|
export 'src/widgets/editor.dart';
|
||||||
|
export 'src/widgets/toolbar.dart';
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../src/models/documents/attribute.dart';
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../src/models/documents/document.dart';
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../src/models/documents/history.dart';
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../../src/models/documents/nodes/block.dart';
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../../src/models/documents/nodes/container.dart';
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../../src/models/documents/nodes/embed.dart';
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../../src/models/documents/nodes/leaf.dart';
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../../src/models/documents/nodes/line.dart';
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../../src/models/documents/nodes/node.dart';
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../src/models/documents/style.dart';
|
3
app_flowy/packages/editor/lib/models/quill_delta.dart
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../src/models/quill_delta.dart';
|
3
app_flowy/packages/editor/lib/models/rules/delete.dart
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../src/models/rules/delete.dart';
|
3
app_flowy/packages/editor/lib/models/rules/format.dart
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../src/models/rules/format.dart';
|
3
app_flowy/packages/editor/lib/models/rules/insert.dart
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../src/models/rules/insert.dart';
|
3
app_flowy/packages/editor/lib/models/rules/rule.dart
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/// TODO: Remove this file in the next breaking release, because implementation
|
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
|
||||||
|
export '../../src/models/rules/rule.dart';
|
|
@ -0,0 +1,314 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:quiver/core.dart';
|
||||||
|
|
||||||
|
enum AttributeScope {
|
||||||
|
INLINE, // refer to https://quilljs.com/docs/formats/#inline
|
||||||
|
BLOCK, // refer to https://quilljs.com/docs/formats/#block
|
||||||
|
EMBEDS, // refer to https://quilljs.com/docs/formats/#embeds
|
||||||
|
IGNORE, // attributes that can be ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
class Attribute<T> {
|
||||||
|
Attribute(this.key, this.scope, this.value);
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
final AttributeScope scope;
|
||||||
|
final T value;
|
||||||
|
|
||||||
|
static final Map<String, Attribute> _registry = LinkedHashMap.of({
|
||||||
|
Attribute.bold.key: Attribute.bold,
|
||||||
|
Attribute.italic.key: Attribute.italic,
|
||||||
|
Attribute.small.key: Attribute.small,
|
||||||
|
Attribute.underline.key: Attribute.underline,
|
||||||
|
Attribute.strikeThrough.key: Attribute.strikeThrough,
|
||||||
|
Attribute.inlineCode.key: Attribute.inlineCode,
|
||||||
|
Attribute.font.key: Attribute.font,
|
||||||
|
Attribute.size.key: Attribute.size,
|
||||||
|
Attribute.link.key: Attribute.link,
|
||||||
|
Attribute.color.key: Attribute.color,
|
||||||
|
Attribute.background.key: Attribute.background,
|
||||||
|
Attribute.placeholder.key: Attribute.placeholder,
|
||||||
|
Attribute.header.key: Attribute.header,
|
||||||
|
Attribute.align.key: Attribute.align,
|
||||||
|
Attribute.list.key: Attribute.list,
|
||||||
|
Attribute.codeBlock.key: Attribute.codeBlock,
|
||||||
|
Attribute.blockQuote.key: Attribute.blockQuote,
|
||||||
|
Attribute.indent.key: Attribute.indent,
|
||||||
|
Attribute.width.key: Attribute.width,
|
||||||
|
Attribute.height.key: Attribute.height,
|
||||||
|
Attribute.style.key: Attribute.style,
|
||||||
|
Attribute.token.key: Attribute.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
static final BoldAttribute bold = BoldAttribute();
|
||||||
|
|
||||||
|
static final ItalicAttribute italic = ItalicAttribute();
|
||||||
|
|
||||||
|
static final SmallAttribute small = SmallAttribute();
|
||||||
|
|
||||||
|
static final UnderlineAttribute underline = UnderlineAttribute();
|
||||||
|
|
||||||
|
static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute();
|
||||||
|
|
||||||
|
static final InlineCodeAttribute inlineCode = InlineCodeAttribute();
|
||||||
|
|
||||||
|
static final FontAttribute font = FontAttribute(null);
|
||||||
|
|
||||||
|
static final SizeAttribute size = SizeAttribute(null);
|
||||||
|
|
||||||
|
static final LinkAttribute link = LinkAttribute(null);
|
||||||
|
|
||||||
|
static final ColorAttribute color = ColorAttribute(null);
|
||||||
|
|
||||||
|
static final BackgroundAttribute background = BackgroundAttribute(null);
|
||||||
|
|
||||||
|
static final PlaceholderAttribute placeholder = PlaceholderAttribute();
|
||||||
|
|
||||||
|
static final HeaderAttribute header = HeaderAttribute();
|
||||||
|
|
||||||
|
static final IndentAttribute indent = IndentAttribute();
|
||||||
|
|
||||||
|
static final AlignAttribute align = AlignAttribute(null);
|
||||||
|
|
||||||
|
static final ListAttribute list = ListAttribute(null);
|
||||||
|
|
||||||
|
static final CodeBlockAttribute codeBlock = CodeBlockAttribute();
|
||||||
|
|
||||||
|
static final BlockQuoteAttribute blockQuote = BlockQuoteAttribute();
|
||||||
|
|
||||||
|
static final WidthAttribute width = WidthAttribute(null);
|
||||||
|
|
||||||
|
static final HeightAttribute height = HeightAttribute(null);
|
||||||
|
|
||||||
|
static final StyleAttribute style = StyleAttribute(null);
|
||||||
|
|
||||||
|
static final TokenAttribute token = TokenAttribute('');
|
||||||
|
|
||||||
|
static final Set<String> inlineKeys = {
|
||||||
|
Attribute.bold.key,
|
||||||
|
Attribute.italic.key,
|
||||||
|
Attribute.small.key,
|
||||||
|
Attribute.underline.key,
|
||||||
|
Attribute.strikeThrough.key,
|
||||||
|
Attribute.link.key,
|
||||||
|
Attribute.color.key,
|
||||||
|
Attribute.background.key,
|
||||||
|
Attribute.placeholder.key,
|
||||||
|
};
|
||||||
|
|
||||||
|
static final Set<String> blockKeys = LinkedHashSet.of({
|
||||||
|
Attribute.header.key,
|
||||||
|
Attribute.align.key,
|
||||||
|
Attribute.list.key,
|
||||||
|
Attribute.codeBlock.key,
|
||||||
|
Attribute.blockQuote.key,
|
||||||
|
Attribute.indent.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
static final Set<String> blockKeysExceptHeader = LinkedHashSet.of({
|
||||||
|
Attribute.list.key,
|
||||||
|
Attribute.align.key,
|
||||||
|
Attribute.codeBlock.key,
|
||||||
|
Attribute.blockQuote.key,
|
||||||
|
Attribute.indent.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
static final Set<String> exclusiveBlockKeys = LinkedHashSet.of({
|
||||||
|
Attribute.header.key,
|
||||||
|
Attribute.list.key,
|
||||||
|
Attribute.codeBlock.key,
|
||||||
|
Attribute.blockQuote.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Attribute<int?> get h1 => HeaderAttribute(level: 1);
|
||||||
|
|
||||||
|
static Attribute<int?> get h2 => HeaderAttribute(level: 2);
|
||||||
|
|
||||||
|
static Attribute<int?> get h3 => HeaderAttribute(level: 3);
|
||||||
|
|
||||||
|
// "attributes":{"align":"left"}
|
||||||
|
static Attribute<String?> get leftAlignment => AlignAttribute('left');
|
||||||
|
|
||||||
|
// "attributes":{"align":"center"}
|
||||||
|
static Attribute<String?> get centerAlignment => AlignAttribute('center');
|
||||||
|
|
||||||
|
// "attributes":{"align":"right"}
|
||||||
|
static Attribute<String?> get rightAlignment => AlignAttribute('right');
|
||||||
|
|
||||||
|
// "attributes":{"align":"justify"}
|
||||||
|
static Attribute<String?> get justifyAlignment => AlignAttribute('justify');
|
||||||
|
|
||||||
|
// "attributes":{"list":"bullet"}
|
||||||
|
static Attribute<String?> get ul => ListAttribute('bullet');
|
||||||
|
|
||||||
|
// "attributes":{"list":"ordered"}
|
||||||
|
static Attribute<String?> get ol => ListAttribute('ordered');
|
||||||
|
|
||||||
|
// "attributes":{"list":"checked"}
|
||||||
|
static Attribute<String?> get checked => ListAttribute('checked');
|
||||||
|
|
||||||
|
// "attributes":{"list":"unchecked"}
|
||||||
|
static Attribute<String?> get unchecked => ListAttribute('unchecked');
|
||||||
|
|
||||||
|
// "attributes":{"indent":1"}
|
||||||
|
static Attribute<int?> get indentL1 => IndentAttribute(level: 1);
|
||||||
|
|
||||||
|
// "attributes":{"indent":2"}
|
||||||
|
static Attribute<int?> get indentL2 => IndentAttribute(level: 2);
|
||||||
|
|
||||||
|
// "attributes":{"indent":3"}
|
||||||
|
static Attribute<int?> get indentL3 => IndentAttribute(level: 3);
|
||||||
|
|
||||||
|
static Attribute<int?> getIndentLevel(int? level) {
|
||||||
|
if (level == 1) {
|
||||||
|
return indentL1;
|
||||||
|
}
|
||||||
|
if (level == 2) {
|
||||||
|
return indentL2;
|
||||||
|
}
|
||||||
|
if (level == 3) {
|
||||||
|
return indentL3;
|
||||||
|
}
|
||||||
|
return IndentAttribute(level: level);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isInline => scope == AttributeScope.INLINE;
|
||||||
|
|
||||||
|
bool get isBlockExceptHeader => blockKeysExceptHeader.contains(key);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => <String, dynamic>{key: value};
|
||||||
|
|
||||||
|
static Attribute? fromKeyValue(String key, dynamic value) {
|
||||||
|
final origin = _registry[key];
|
||||||
|
if (origin == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final attribute = clone(origin, value);
|
||||||
|
return attribute;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int getRegistryOrder(Attribute attribute) {
|
||||||
|
var order = 0;
|
||||||
|
for (final attr in _registry.values) {
|
||||||
|
if (attr.key == attribute.key) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
order++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Attribute clone(Attribute origin, dynamic value) {
|
||||||
|
return Attribute(origin.key, origin.scope, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other is! Attribute) return false;
|
||||||
|
final typedOther = other;
|
||||||
|
return key == typedOther.key &&
|
||||||
|
scope == typedOther.scope &&
|
||||||
|
value == typedOther.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => hash3(key, scope, value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Attribute{key: $key, scope: $scope, value: $value}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BoldAttribute extends Attribute<bool> {
|
||||||
|
BoldAttribute() : super('bold', AttributeScope.INLINE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ItalicAttribute extends Attribute<bool> {
|
||||||
|
ItalicAttribute() : super('italic', AttributeScope.INLINE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SmallAttribute extends Attribute<bool> {
|
||||||
|
SmallAttribute() : super('small', AttributeScope.INLINE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnderlineAttribute extends Attribute<bool> {
|
||||||
|
UnderlineAttribute() : super('underline', AttributeScope.INLINE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StrikeThroughAttribute extends Attribute<bool> {
|
||||||
|
StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class InlineCodeAttribute extends Attribute<bool> {
|
||||||
|
InlineCodeAttribute() : super('code', AttributeScope.INLINE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FontAttribute extends Attribute<String?> {
|
||||||
|
FontAttribute(String? val) : super('font', AttributeScope.INLINE, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SizeAttribute extends Attribute<String?> {
|
||||||
|
SizeAttribute(String? val) : super('size', AttributeScope.INLINE, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LinkAttribute extends Attribute<String?> {
|
||||||
|
LinkAttribute(String? val) : super('link', AttributeScope.INLINE, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ColorAttribute extends Attribute<String?> {
|
||||||
|
ColorAttribute(String? val) : super('color', AttributeScope.INLINE, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundAttribute extends Attribute<String?> {
|
||||||
|
BackgroundAttribute(String? val)
|
||||||
|
: super('background', AttributeScope.INLINE, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is custom attribute for hint
|
||||||
|
class PlaceholderAttribute extends Attribute<bool> {
|
||||||
|
PlaceholderAttribute() : super('placeholder', AttributeScope.INLINE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class HeaderAttribute extends Attribute<int?> {
|
||||||
|
HeaderAttribute({int? level}) : super('header', AttributeScope.BLOCK, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
class IndentAttribute extends Attribute<int?> {
|
||||||
|
IndentAttribute({int? level}) : super('indent', AttributeScope.BLOCK, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlignAttribute extends Attribute<String?> {
|
||||||
|
AlignAttribute(String? val) : super('align', AttributeScope.BLOCK, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListAttribute extends Attribute<String?> {
|
||||||
|
ListAttribute(String? val) : super('list', AttributeScope.BLOCK, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CodeBlockAttribute extends Attribute<bool> {
|
||||||
|
CodeBlockAttribute() : super('code-block', AttributeScope.BLOCK, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockQuoteAttribute extends Attribute<bool> {
|
||||||
|
BlockQuoteAttribute() : super('blockquote', AttributeScope.BLOCK, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WidthAttribute extends Attribute<String?> {
|
||||||
|
WidthAttribute(String? val) : super('width', AttributeScope.IGNORE, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
class HeightAttribute extends Attribute<String?> {
|
||||||
|
HeightAttribute(String? val) : super('height', AttributeScope.IGNORE, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StyleAttribute extends Attribute<String?> {
|
||||||
|
StyleAttribute(String? val) : super('style', AttributeScope.IGNORE, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TokenAttribute extends Attribute<String> {
|
||||||
|
TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val);
|
||||||
|
}
|
291
app_flowy/packages/editor/lib/src/models/documents/document.dart
Normal file
|
@ -0,0 +1,291 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
import '../quill_delta.dart';
|
||||||
|
import '../rules/rule.dart';
|
||||||
|
import 'attribute.dart';
|
||||||
|
import 'history.dart';
|
||||||
|
import 'nodes/block.dart';
|
||||||
|
import 'nodes/container.dart';
|
||||||
|
import 'nodes/embed.dart';
|
||||||
|
import 'nodes/line.dart';
|
||||||
|
import 'nodes/node.dart';
|
||||||
|
import 'style.dart';
|
||||||
|
|
||||||
|
/// The rich text document
|
||||||
|
class Document {
|
||||||
|
Document() : _delta = Delta()..insert('\n') {
|
||||||
|
_loadDocument(_delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
Document.fromJson(List data) : _delta = _transform(Delta.fromJson(data)) {
|
||||||
|
_loadDocument(_delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
Document.fromDelta(Delta delta) : _delta = delta {
|
||||||
|
_loadDocument(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The root node of the document tree
|
||||||
|
final Root _root = Root();
|
||||||
|
|
||||||
|
Root get root => _root;
|
||||||
|
|
||||||
|
int get length => _root.length;
|
||||||
|
|
||||||
|
Delta _delta;
|
||||||
|
|
||||||
|
Delta toDelta() => Delta.from(_delta);
|
||||||
|
|
||||||
|
final Rules _rules = Rules.getInstance();
|
||||||
|
|
||||||
|
void setCustomRules(List<Rule> customRules) {
|
||||||
|
_rules.setCustomRules(customRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
final StreamController<Tuple3<Delta, Delta, ChangeSource>> _observer =
|
||||||
|
StreamController.broadcast();
|
||||||
|
|
||||||
|
final History _history = History();
|
||||||
|
|
||||||
|
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => _observer.stream;
|
||||||
|
|
||||||
|
Delta insert(int index, Object? data, {int replaceLength = 0}) {
|
||||||
|
assert(index >= 0);
|
||||||
|
assert(data is String || data is Embeddable);
|
||||||
|
if (data is Embeddable) {
|
||||||
|
data = data.toJson();
|
||||||
|
} else if ((data as String).isEmpty) {
|
||||||
|
return Delta();
|
||||||
|
}
|
||||||
|
|
||||||
|
final delta = _rules.apply(RuleType.INSERT, this, index,
|
||||||
|
data: data, len: replaceLength);
|
||||||
|
compose(delta, ChangeSource.LOCAL);
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
Delta delete(int index, int len) {
|
||||||
|
assert(index >= 0 && len > 0);
|
||||||
|
final delta = _rules.apply(RuleType.DELETE, this, index, len: len);
|
||||||
|
if (delta.isNotEmpty) {
|
||||||
|
compose(delta, ChangeSource.LOCAL);
|
||||||
|
}
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
Delta replace(int index, int len, Object? data) {
|
||||||
|
assert(index >= 0);
|
||||||
|
assert(data is String || data is Embeddable);
|
||||||
|
|
||||||
|
final dataIsNotEmpty = (data is String) ? data.isNotEmpty : true;
|
||||||
|
|
||||||
|
assert(dataIsNotEmpty || len > 0);
|
||||||
|
|
||||||
|
var delta = Delta();
|
||||||
|
|
||||||
|
// We have to insert before applying delete rules
|
||||||
|
// Otherwise delete would be operating on stale document snapshot.
|
||||||
|
if (dataIsNotEmpty) {
|
||||||
|
delta = insert(index, data, replaceLength: len);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len > 0) {
|
||||||
|
final deleteDelta = delete(index, len);
|
||||||
|
delta = delta.compose(deleteDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
Delta format(int index, int len, Attribute? attribute) {
|
||||||
|
assert(index >= 0 && len >= 0 && attribute != null);
|
||||||
|
|
||||||
|
var delta = Delta();
|
||||||
|
|
||||||
|
final formatDelta = _rules.apply(RuleType.FORMAT, this, index,
|
||||||
|
len: len, attribute: attribute);
|
||||||
|
if (formatDelta.isNotEmpty) {
|
||||||
|
compose(formatDelta, ChangeSource.LOCAL);
|
||||||
|
delta = delta.compose(formatDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only attributes applied to all characters within this range are
|
||||||
|
/// included in the result.
|
||||||
|
Style collectStyle(int index, int len) {
|
||||||
|
final res = queryChild(index);
|
||||||
|
return (res.node as Line).collectStyle(res.offset, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all styles for any character within the specified text range.
|
||||||
|
List<Style> collectAllStyles(int index, int len) {
|
||||||
|
final res = queryChild(index);
|
||||||
|
return (res.node as Line).collectAllStyles(res.offset, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChildQuery queryChild(int offset) {
|
||||||
|
final res = _root.queryChild(offset, true);
|
||||||
|
if (res.node is Line) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
final block = res.node as Block;
|
||||||
|
return block.queryChild(res.offset, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void compose(Delta delta, ChangeSource changeSource) {
|
||||||
|
assert(!_observer.isClosed);
|
||||||
|
delta.trim();
|
||||||
|
assert(delta.isNotEmpty);
|
||||||
|
|
||||||
|
var offset = 0;
|
||||||
|
delta = _transform(delta);
|
||||||
|
final originalDelta = toDelta();
|
||||||
|
for (final op in delta.toList()) {
|
||||||
|
final style =
|
||||||
|
op.attributes != null ? Style.fromJson(op.attributes) : null;
|
||||||
|
|
||||||
|
if (op.isInsert) {
|
||||||
|
_root.insert(offset, _normalize(op.data), style);
|
||||||
|
} else if (op.isDelete) {
|
||||||
|
_root.delete(offset, op.length);
|
||||||
|
} else if (op.attributes != null) {
|
||||||
|
_root.retain(offset, op.length, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!op.isDelete) {
|
||||||
|
offset += op.length!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
_delta = _delta.compose(delta);
|
||||||
|
} catch (e) {
|
||||||
|
throw '_delta compose failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_delta != _root.toDelta()) {
|
||||||
|
throw 'Compose failed';
|
||||||
|
}
|
||||||
|
final change = Tuple3(originalDelta, delta, changeSource);
|
||||||
|
_observer.add(change);
|
||||||
|
_history.handleDocChange(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2 undo() {
|
||||||
|
return _history.undo(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2 redo() {
|
||||||
|
return _history.redo(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasUndo => _history.hasUndo;
|
||||||
|
|
||||||
|
bool get hasRedo => _history.hasRedo;
|
||||||
|
|
||||||
|
static Delta _transform(Delta delta) {
|
||||||
|
final res = Delta();
|
||||||
|
final ops = delta.toList();
|
||||||
|
for (var i = 0; i < ops.length; i++) {
|
||||||
|
final op = ops[i];
|
||||||
|
res.push(op);
|
||||||
|
_autoAppendNewlineAfterEmbeddable(i, ops, op, res, 'video');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _autoAppendNewlineAfterEmbeddable(
|
||||||
|
int i, List<Operation> ops, Operation op, Delta res, String type) {
|
||||||
|
final nextOpIsEmbed = i + 1 < ops.length &&
|
||||||
|
ops[i + 1].isInsert &&
|
||||||
|
ops[i + 1].data is Map &&
|
||||||
|
(ops[i + 1].data as Map).containsKey(type);
|
||||||
|
if (nextOpIsEmbed &&
|
||||||
|
op.data is String &&
|
||||||
|
(op.data as String).isNotEmpty &&
|
||||||
|
!(op.data as String).endsWith('\n')) {
|
||||||
|
res.push(Operation.insert('\n'));
|
||||||
|
}
|
||||||
|
// embed could be image or video
|
||||||
|
final opInsertEmbed =
|
||||||
|
op.isInsert && op.data is Map && (op.data as Map).containsKey(type);
|
||||||
|
final nextOpIsLineBreak = i + 1 < ops.length &&
|
||||||
|
ops[i + 1].isInsert &&
|
||||||
|
ops[i + 1].data is String &&
|
||||||
|
(ops[i + 1].data as String).startsWith('\n');
|
||||||
|
if (opInsertEmbed && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) {
|
||||||
|
// automatically append '\n' for embeddable
|
||||||
|
res.push(Operation.insert('\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object _normalize(Object? data) {
|
||||||
|
if (data is String) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data is Embeddable) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return Embeddable.fromJson(data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
_observer.close();
|
||||||
|
_history.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
String toPlainText() => _root.children.map((e) => e.toPlainText()).join();
|
||||||
|
|
||||||
|
void _loadDocument(Delta doc) {
|
||||||
|
if (doc.isEmpty) {
|
||||||
|
throw ArgumentError.value(doc, 'Document Delta cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
assert((doc.last.data as String).endsWith('\n'));
|
||||||
|
|
||||||
|
var offset = 0;
|
||||||
|
for (final op in doc.toList()) {
|
||||||
|
if (!op.isInsert) {
|
||||||
|
throw ArgumentError.value(doc,
|
||||||
|
'Document can only contain insert operations but ${op.key} found.');
|
||||||
|
}
|
||||||
|
final style =
|
||||||
|
op.attributes != null ? Style.fromJson(op.attributes) : null;
|
||||||
|
final data = _normalize(op.data);
|
||||||
|
_root.insert(offset, data, style);
|
||||||
|
offset += op.length!;
|
||||||
|
}
|
||||||
|
final node = _root.last;
|
||||||
|
if (node is Line &&
|
||||||
|
node.parent is! Block &&
|
||||||
|
node.style.isEmpty &&
|
||||||
|
_root.childCount > 1) {
|
||||||
|
_root.remove(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isEmpty() {
|
||||||
|
if (root.children.length != 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final node = root.children.first;
|
||||||
|
if (!node.isLast) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final delta = node.toDelta();
|
||||||
|
return delta.length == 1 &&
|
||||||
|
delta.first.data == '\n' &&
|
||||||
|
delta.first.key == 'insert';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChangeSource {
|
||||||
|
LOCAL,
|
||||||
|
REMOTE,
|
||||||
|
}
|
134
app_flowy/packages/editor/lib/src/models/documents/history.dart
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
import '../quill_delta.dart';
|
||||||
|
import 'document.dart';
|
||||||
|
|
||||||
|
class History {
|
||||||
|
History({
|
||||||
|
this.ignoreChange = false,
|
||||||
|
this.interval = 400,
|
||||||
|
this.maxStack = 100,
|
||||||
|
this.userOnly = false,
|
||||||
|
this.lastRecorded = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HistoryStack stack = HistoryStack.empty();
|
||||||
|
|
||||||
|
bool get hasUndo => stack.undo.isNotEmpty;
|
||||||
|
|
||||||
|
bool get hasRedo => stack.redo.isNotEmpty;
|
||||||
|
|
||||||
|
/// used for disable redo or undo function
|
||||||
|
bool ignoreChange;
|
||||||
|
|
||||||
|
int lastRecorded;
|
||||||
|
|
||||||
|
/// Collaborative editing's conditions should be true
|
||||||
|
final bool userOnly;
|
||||||
|
|
||||||
|
///max operation count for undo
|
||||||
|
final int maxStack;
|
||||||
|
|
||||||
|
///record delay
|
||||||
|
final int interval;
|
||||||
|
|
||||||
|
void handleDocChange(Tuple3<Delta, Delta, ChangeSource> change) {
|
||||||
|
if (ignoreChange) return;
|
||||||
|
if (!userOnly || change.item3 == ChangeSource.LOCAL) {
|
||||||
|
record(change.item2, change.item1);
|
||||||
|
} else {
|
||||||
|
transform(change.item2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
stack.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void record(Delta change, Delta before) {
|
||||||
|
if (change.isEmpty) return;
|
||||||
|
stack.redo.clear();
|
||||||
|
var undoDelta = change.invert(before);
|
||||||
|
final timeStamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
if (lastRecorded + interval > timeStamp && stack.undo.isNotEmpty) {
|
||||||
|
final lastDelta = stack.undo.removeLast();
|
||||||
|
undoDelta = undoDelta.compose(lastDelta);
|
||||||
|
} else {
|
||||||
|
lastRecorded = timeStamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (undoDelta.isEmpty) return;
|
||||||
|
stack.undo.add(undoDelta);
|
||||||
|
|
||||||
|
if (stack.undo.length > maxStack) {
|
||||||
|
stack.undo.removeAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
///It will override pre local undo delta,replaced by remote change
|
||||||
|
///
|
||||||
|
void transform(Delta delta) {
|
||||||
|
transformStack(stack.undo, delta);
|
||||||
|
transformStack(stack.redo, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
void transformStack(List<Delta> stack, Delta delta) {
|
||||||
|
for (var i = stack.length - 1; i >= 0; i -= 1) {
|
||||||
|
final oldDelta = stack[i];
|
||||||
|
stack[i] = delta.transform(oldDelta, true);
|
||||||
|
delta = oldDelta.transform(delta, false);
|
||||||
|
if (stack[i].length == 0) {
|
||||||
|
stack.removeAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2 _change(Document doc, List<Delta> source, List<Delta> dest) {
|
||||||
|
if (source.isEmpty) {
|
||||||
|
return const Tuple2(false, 0);
|
||||||
|
}
|
||||||
|
final delta = source.removeLast();
|
||||||
|
// look for insert or delete
|
||||||
|
int? len = 0;
|
||||||
|
final ops = delta.toList();
|
||||||
|
for (var i = 0; i < ops.length; i++) {
|
||||||
|
if (ops[i].key == Operation.insertKey) {
|
||||||
|
len = ops[i].length;
|
||||||
|
} else if (ops[i].key == Operation.deleteKey) {
|
||||||
|
len = ops[i].length! * -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final base = Delta.from(doc.toDelta());
|
||||||
|
final inverseDelta = delta.invert(base);
|
||||||
|
dest.add(inverseDelta);
|
||||||
|
lastRecorded = 0;
|
||||||
|
ignoreChange = true;
|
||||||
|
doc.compose(delta, ChangeSource.LOCAL);
|
||||||
|
ignoreChange = false;
|
||||||
|
return Tuple2(true, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2 undo(Document doc) {
|
||||||
|
return _change(doc, stack.undo, stack.redo);
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2 redo(Document doc) {
|
||||||
|
return _change(doc, stack.redo, stack.undo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HistoryStack {
|
||||||
|
HistoryStack.empty()
|
||||||
|
: undo = [],
|
||||||
|
redo = [];
|
||||||
|
|
||||||
|
final List<Delta> undo;
|
||||||
|
final List<Delta> redo;
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
undo.clear();
|
||||||
|
redo.clear();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
import '../../quill_delta.dart';
|
||||||
|
import 'container.dart';
|
||||||
|
import 'line.dart';
|
||||||
|
import 'node.dart';
|
||||||
|
|
||||||
|
/// Represents a group of adjacent [Line]s with the same block style.
|
||||||
|
///
|
||||||
|
/// Block elements are:
|
||||||
|
/// - Blockquote
|
||||||
|
/// - Header
|
||||||
|
/// - Indent
|
||||||
|
/// - List
|
||||||
|
/// - Text Alignment
|
||||||
|
/// - Text Direction
|
||||||
|
/// - Code Block
|
||||||
|
class Block extends Container<Line?> {
|
||||||
|
/// Creates new unmounted [Block].
|
||||||
|
@override
|
||||||
|
Node newInstance() => Block();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Line get defaultChild => Line();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta toDelta() {
|
||||||
|
return children
|
||||||
|
.map((child) => child.toDelta())
|
||||||
|
.fold(Delta(), (a, b) => a.concat(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void adjust() {
|
||||||
|
if (isEmpty) {
|
||||||
|
final sibling = previous;
|
||||||
|
unlink();
|
||||||
|
if (sibling != null) {
|
||||||
|
sibling.adjust();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var block = this;
|
||||||
|
final prev = block.previous;
|
||||||
|
// merging it with previous block if style is the same
|
||||||
|
if (!block.isFirst &&
|
||||||
|
block.previous is Block &&
|
||||||
|
prev!.style == block.style) {
|
||||||
|
block
|
||||||
|
..moveChildToNewParent(prev as Container<Node?>?)
|
||||||
|
..unlink();
|
||||||
|
block = prev as Block;
|
||||||
|
}
|
||||||
|
final next = block.next;
|
||||||
|
// merging it with next block if style is the same
|
||||||
|
if (!block.isLast && block.next is Block && next!.style == block.style) {
|
||||||
|
(next as Block).moveChildToNewParent(block);
|
||||||
|
next.unlink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
final block = style.attributes.toString();
|
||||||
|
final buffer = StringBuffer('§ {$block}\n');
|
||||||
|
for (final child in children) {
|
||||||
|
final tree = child.isLast ? '└' : '├';
|
||||||
|
buffer.write(' $tree $child');
|
||||||
|
if (!child.isLast) buffer.writeln();
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,160 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import '../style.dart';
|
||||||
|
import 'leaf.dart';
|
||||||
|
import 'line.dart';
|
||||||
|
import 'node.dart';
|
||||||
|
|
||||||
|
/// Container can accommodate other nodes.
|
||||||
|
///
|
||||||
|
/// Delegates insert, retain and delete operations to children nodes. For each
|
||||||
|
/// operation container looks for a child at specified index position and
|
||||||
|
/// forwards operation to that child.
|
||||||
|
///
|
||||||
|
/// Most of the operation handling logic is implemented by [Line] and [Text].
|
||||||
|
abstract class Container<T extends Node?> extends Node {
|
||||||
|
final LinkedList<Node> _children = LinkedList<Node>();
|
||||||
|
|
||||||
|
/// List of children.
|
||||||
|
LinkedList<Node> get children => _children;
|
||||||
|
|
||||||
|
/// Returns total number of child nodes in this container.
|
||||||
|
///
|
||||||
|
/// To get text length of this container see [length].
|
||||||
|
int get childCount => _children.length;
|
||||||
|
|
||||||
|
/// Returns the first child [Node].
|
||||||
|
Node get first => _children.first;
|
||||||
|
|
||||||
|
/// Returns the last child [Node].
|
||||||
|
Node get last => _children.last;
|
||||||
|
|
||||||
|
/// Returns `true` if this container has no child nodes.
|
||||||
|
bool get isEmpty => _children.isEmpty;
|
||||||
|
|
||||||
|
/// Returns `true` if this container has at least 1 child.
|
||||||
|
bool get isNotEmpty => _children.isNotEmpty;
|
||||||
|
|
||||||
|
/// Returns an instance of default child for this container node.
|
||||||
|
///
|
||||||
|
/// Always returns fresh instance.
|
||||||
|
T get defaultChild;
|
||||||
|
|
||||||
|
/// Adds [node] to the end of this container children list.
|
||||||
|
void add(T node) {
|
||||||
|
assert(node?.parent == null);
|
||||||
|
node?.parent = this;
|
||||||
|
_children.add(node as Node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds [node] to the beginning of this container children list.
|
||||||
|
void addFirst(T node) {
|
||||||
|
assert(node?.parent == null);
|
||||||
|
node?.parent = this;
|
||||||
|
_children.addFirst(node as Node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes [node] from this container.
|
||||||
|
void remove(T node) {
|
||||||
|
assert(node?.parent == this);
|
||||||
|
node?.parent = null;
|
||||||
|
_children.remove(node as Node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves children of this node to [newParent].
|
||||||
|
void moveChildToNewParent(Container? newParent) {
|
||||||
|
if (isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final last = newParent!.isEmpty ? null : newParent.last as T?;
|
||||||
|
while (isNotEmpty) {
|
||||||
|
final child = first as T;
|
||||||
|
child?.unlink();
|
||||||
|
newParent.add(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In case [newParent] already had children we need to make sure
|
||||||
|
/// combined list is optimized.
|
||||||
|
if (last != null) last.adjust();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries the child [Node] at [offset] in this container.
|
||||||
|
///
|
||||||
|
/// The result may contain the found node or `null` if no node is found
|
||||||
|
/// at specified offset.
|
||||||
|
///
|
||||||
|
/// [ChildQuery.offset] is set to relative offset within returned child node
|
||||||
|
/// which points at the same character position in the document as the
|
||||||
|
/// original [offset].
|
||||||
|
ChildQuery queryChild(int offset, bool inclusive) {
|
||||||
|
if (offset < 0 || offset > length) {
|
||||||
|
return ChildQuery(null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final node in children) {
|
||||||
|
final len = node.length;
|
||||||
|
if (offset < len || (inclusive && offset == len && node.isLast)) {
|
||||||
|
return ChildQuery(node, offset);
|
||||||
|
}
|
||||||
|
offset -= len;
|
||||||
|
}
|
||||||
|
return ChildQuery(null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toPlainText() => children.map((child) => child.toPlainText()).join();
|
||||||
|
|
||||||
|
/// Content length of this node's children.
|
||||||
|
///
|
||||||
|
/// To get number of children in this node use [childCount].
|
||||||
|
@override
|
||||||
|
int get length => _children.fold(0, (cur, node) => cur + node.length);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void insert(int index, Object data, Style? style) {
|
||||||
|
assert(index == 0 || (index > 0 && index < length));
|
||||||
|
|
||||||
|
if (isNotEmpty) {
|
||||||
|
final child = queryChild(index, false);
|
||||||
|
child.node!.insert(child.offset, data, style);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty
|
||||||
|
assert(index == 0);
|
||||||
|
final node = defaultChild;
|
||||||
|
add(node);
|
||||||
|
node?.insert(index, data, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void retain(int index, int? length, Style? attributes) {
|
||||||
|
assert(isNotEmpty);
|
||||||
|
final child = queryChild(index, false);
|
||||||
|
child.node!.retain(child.offset, length, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void delete(int index, int? length) {
|
||||||
|
assert(isNotEmpty);
|
||||||
|
final child = queryChild(index, false);
|
||||||
|
child.node!.delete(child.offset, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _children.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a child query in a [Container].
|
||||||
|
class ChildQuery {
|
||||||
|
ChildQuery(this.node, this.offset);
|
||||||
|
|
||||||
|
/// The child node if found, otherwise `null`.
|
||||||
|
final Node? node;
|
||||||
|
|
||||||
|
/// Starting offset within the child [node] which points at the same
|
||||||
|
/// character in the document as the original offset passed to
|
||||||
|
/// [Container.queryChild] method.
|
||||||
|
final int offset;
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
/// An object which can be embedded into a Quill document.
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [BlockEmbed] which represents a block embed.
|
||||||
|
class Embeddable {
|
||||||
|
const Embeddable(this.type, this.data);
|
||||||
|
|
||||||
|
/// The type of this object.
|
||||||
|
final String type;
|
||||||
|
|
||||||
|
/// The data payload of this object.
|
||||||
|
final dynamic data;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final m = <String, String>{type: data};
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Embeddable fromJson(Map<String, dynamic> json) {
|
||||||
|
final m = Map<String, dynamic>.from(json);
|
||||||
|
assert(m.length == 1, 'Embeddable map has one key');
|
||||||
|
|
||||||
|
return BlockEmbed(m.keys.first, m.values.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An object which occupies an entire line in a document and cannot co-exist
|
||||||
|
/// inline with regular text.
|
||||||
|
///
|
||||||
|
/// There are two built-in embed types supported by Quill documents, however
|
||||||
|
/// the document model itself does not make any assumptions about the types
|
||||||
|
/// of embedded objects and allows users to define their own types.
|
||||||
|
class BlockEmbed extends Embeddable {
|
||||||
|
const BlockEmbed(String type, String data) : super(type, data);
|
||||||
|
|
||||||
|
static const String horizontalRuleType = 'divider';
|
||||||
|
static BlockEmbed horizontalRule = const BlockEmbed(horizontalRuleType, 'hr');
|
||||||
|
|
||||||
|
static const String imageType = 'image';
|
||||||
|
static BlockEmbed image(String imageUrl) => BlockEmbed(imageType, imageUrl);
|
||||||
|
|
||||||
|
static const String videoType = 'video';
|
||||||
|
static BlockEmbed video(String videoUrl) => BlockEmbed(videoType, videoUrl);
|
||||||
|
}
|
|
@ -0,0 +1,252 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import '../../quill_delta.dart';
|
||||||
|
import '../style.dart';
|
||||||
|
import 'embed.dart';
|
||||||
|
import 'line.dart';
|
||||||
|
import 'node.dart';
|
||||||
|
|
||||||
|
/// A leaf in Quill document tree.
|
||||||
|
abstract class Leaf extends Node {
|
||||||
|
/// Creates a new [Leaf] with specified [data].
|
||||||
|
factory Leaf(Object data) {
|
||||||
|
if (data is Embeddable) {
|
||||||
|
return Embed(data);
|
||||||
|
}
|
||||||
|
final text = data as String;
|
||||||
|
assert(text.isNotEmpty);
|
||||||
|
return Text(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Leaf.val(Object val) : _value = val;
|
||||||
|
|
||||||
|
/// Contents of this node, either a String if this is a [Text] or an
|
||||||
|
/// [Embed] if this is an [BlockEmbed].
|
||||||
|
Object get value => _value;
|
||||||
|
Object _value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void applyStyle(Style value) {
|
||||||
|
assert(value.isInline || value.isIgnored || value.isEmpty,
|
||||||
|
'Unable to apply Style to leaf: $value');
|
||||||
|
super.applyStyle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Line? get parent => super.parent as Line?;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get length {
|
||||||
|
if (_value is String) {
|
||||||
|
return (_value as String).length;
|
||||||
|
}
|
||||||
|
// return 1 for embedded object
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta toDelta() {
|
||||||
|
final data =
|
||||||
|
_value is Embeddable ? (_value as Embeddable).toJson() : _value;
|
||||||
|
return Delta()..insert(data, style.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void insert(int index, Object data, Style? style) {
|
||||||
|
assert(index >= 0 && index <= length);
|
||||||
|
final node = Leaf(data);
|
||||||
|
if (index < length) {
|
||||||
|
splitAt(index)!.insertBefore(node);
|
||||||
|
} else {
|
||||||
|
insertAfter(node);
|
||||||
|
}
|
||||||
|
node.format(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void retain(int index, int? len, Style? style) {
|
||||||
|
if (style == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final local = math.min(length - index, len!);
|
||||||
|
final remain = len - local;
|
||||||
|
final node = _isolate(index, local);
|
||||||
|
|
||||||
|
if (remain > 0) {
|
||||||
|
assert(node.next != null);
|
||||||
|
node.next!.retain(0, remain, style);
|
||||||
|
}
|
||||||
|
node.format(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void delete(int index, int? len) {
|
||||||
|
assert(index < length);
|
||||||
|
|
||||||
|
final local = math.min(length - index, len!);
|
||||||
|
final target = _isolate(index, local);
|
||||||
|
final prev = target.previous as Leaf?;
|
||||||
|
final next = target.next as Leaf?;
|
||||||
|
target.unlink();
|
||||||
|
|
||||||
|
final remain = len - local;
|
||||||
|
if (remain > 0) {
|
||||||
|
assert(next != null);
|
||||||
|
next!.delete(0, remain);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev != null) {
|
||||||
|
prev.adjust();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust this text node by merging it with adjacent nodes if they share
|
||||||
|
/// the same style.
|
||||||
|
@override
|
||||||
|
void adjust() {
|
||||||
|
if (this is Embed) {
|
||||||
|
// Embed nodes cannot be merged with text nor other embeds (in fact,
|
||||||
|
// there could be no two adjacent embeds on the same line since an
|
||||||
|
// embed occupies an entire line).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a text node and it can only be merged with other text nodes.
|
||||||
|
var node = this as Text;
|
||||||
|
|
||||||
|
// Merging it with previous node if style is the same.
|
||||||
|
final prev = node.previous;
|
||||||
|
if (!node.isFirst && prev is Text && prev.style == node.style) {
|
||||||
|
prev._value = prev.value + node.value;
|
||||||
|
node.unlink();
|
||||||
|
node = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merging it with next node if style is the same.
|
||||||
|
final next = node.next;
|
||||||
|
if (!node.isLast && next is Text && next.style == node.style) {
|
||||||
|
node._value = node.value + next.value;
|
||||||
|
next.unlink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits this leaf node at [index] and returns new node.
|
||||||
|
///
|
||||||
|
/// If this is the last node in its list and [index] equals this node's
|
||||||
|
/// length then this method returns `null` as there is nothing left to split.
|
||||||
|
/// If there is another leaf node after this one and [index] equals this
|
||||||
|
/// node's length then the next leaf node is returned.
|
||||||
|
///
|
||||||
|
/// If [index] equals to `0` then this node itself is returned unchanged.
|
||||||
|
///
|
||||||
|
/// In case a new node is actually split from this one, it inherits this
|
||||||
|
/// node's style.
|
||||||
|
Leaf? splitAt(int index) {
|
||||||
|
assert(index >= 0 && index <= length);
|
||||||
|
if (index == 0) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
if (index == length) {
|
||||||
|
return isLast ? null : next as Leaf?;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(this is Text);
|
||||||
|
final text = _value as String;
|
||||||
|
_value = text.substring(0, index);
|
||||||
|
final split = Leaf(text.substring(index))..applyStyle(style);
|
||||||
|
insertAfter(split);
|
||||||
|
return split;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cuts a leaf from [index] to the end of this node and returns new node
|
||||||
|
/// in detached state (e.g. [mounted] returns `false`).
|
||||||
|
///
|
||||||
|
/// Splitting logic is identical to one described in [splitAt], meaning this
|
||||||
|
/// method may return `null`.
|
||||||
|
Leaf? cutAt(int index) {
|
||||||
|
assert(index >= 0 && index <= length);
|
||||||
|
final cut = splitAt(index);
|
||||||
|
cut?.unlink();
|
||||||
|
return cut;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats this node and optimizes it with adjacent leaf nodes if needed.
|
||||||
|
void format(Style? style) {
|
||||||
|
if (style != null && style.isNotEmpty) {
|
||||||
|
applyStyle(style);
|
||||||
|
}
|
||||||
|
adjust();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Isolates a new leaf starting at [index] with specified [length].
|
||||||
|
///
|
||||||
|
/// Splitting logic is identical to one described in [splitAt], with one
|
||||||
|
/// exception that it is required for [index] to always be less than this
|
||||||
|
/// node's length. As a result this method always returns a [LeafNode]
|
||||||
|
/// instance. Returned node may still be the same as this node
|
||||||
|
/// if provided [index] is `0`.
|
||||||
|
Leaf _isolate(int index, int length) {
|
||||||
|
assert(
|
||||||
|
index >= 0 && index < this.length && (index + length <= this.length));
|
||||||
|
final target = splitAt(index)!..splitAt(length);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A span of formatted text within a line in a Quill document.
|
||||||
|
///
|
||||||
|
/// Text is a leaf node of a document tree.
|
||||||
|
///
|
||||||
|
/// Parent of a text node is always a [Line], and as a consequence text
|
||||||
|
/// node's [value] cannot contain any line-break characters.
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [Embed], a leaf node representing an embeddable object.
|
||||||
|
/// * [Line], a node representing a line of text.
|
||||||
|
class Text extends Leaf {
|
||||||
|
Text([String text = ''])
|
||||||
|
: assert(!text.contains('\n')),
|
||||||
|
super.val(text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Node newInstance() => Text(value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get value => _value as String;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toPlainText() => value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An embed node inside of a line in a Quill document.
|
||||||
|
///
|
||||||
|
/// Embed node is a leaf node similar to [Text]. It represents an arbitrary
|
||||||
|
/// piece of non-textual content embedded into a document, such as, image,
|
||||||
|
/// horizontal rule, video, or any other object with defined structure,
|
||||||
|
/// like a tweet, for instance.
|
||||||
|
///
|
||||||
|
/// Embed node's length is always `1` character and it is represented with
|
||||||
|
/// unicode object replacement character in the document text.
|
||||||
|
///
|
||||||
|
/// Any inline style can be applied to an embed, however this does not
|
||||||
|
/// necessarily mean the embed will look according to that style. For instance,
|
||||||
|
/// applying "bold" style to an image gives no effect, while adding a "link" to
|
||||||
|
/// an image actually makes the image react to user's action.
|
||||||
|
class Embed extends Leaf {
|
||||||
|
Embed(Embeddable data) : super.val(data);
|
||||||
|
|
||||||
|
static const kObjectReplacementCharacter = '\uFFFC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Node newInstance() => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Embeddable get value => super.value as Embeddable;
|
||||||
|
|
||||||
|
/// // Embed nodes are represented as unicode object replacement character in
|
||||||
|
// plain text.
|
||||||
|
@override
|
||||||
|
String toPlainText() => kObjectReplacementCharacter;
|
||||||
|
}
|
|
@ -0,0 +1,414 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
import '../../quill_delta.dart';
|
||||||
|
import '../attribute.dart';
|
||||||
|
import '../style.dart';
|
||||||
|
import 'block.dart';
|
||||||
|
import 'container.dart';
|
||||||
|
import 'embed.dart';
|
||||||
|
import 'leaf.dart';
|
||||||
|
import 'node.dart';
|
||||||
|
|
||||||
|
/// A line of rich text in a Quill document.
|
||||||
|
///
|
||||||
|
/// Line serves as a container for [Leaf]s, like [Text] and [Embed].
|
||||||
|
///
|
||||||
|
/// When a line contains an embed, it fully occupies the line, no other embeds
|
||||||
|
/// or text nodes are allowed.
|
||||||
|
class Line extends Container<Leaf?> {
|
||||||
|
@override
|
||||||
|
Leaf get defaultChild => Text();
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get length => super.length + 1;
|
||||||
|
|
||||||
|
/// Returns `true` if this line contains an embedded object.
|
||||||
|
bool get hasEmbed {
|
||||||
|
return children.any((child) => child is Embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns next [Line] or `null` if this is the last line in the document.
|
||||||
|
Line? get nextLine {
|
||||||
|
if (!isLast) {
|
||||||
|
return next is Block ? (next as Block).first as Line? : next as Line?;
|
||||||
|
}
|
||||||
|
if (parent is! Block) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent!.isLast) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parent!.next is Block
|
||||||
|
? (parent!.next as Block).first as Line?
|
||||||
|
: parent!.next as Line?;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Node newInstance() => Line();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta toDelta() {
|
||||||
|
final delta = children
|
||||||
|
.map((child) => child.toDelta())
|
||||||
|
.fold(Delta(), (dynamic a, b) => a.concat(b));
|
||||||
|
var attributes = style;
|
||||||
|
if (parent is Block) {
|
||||||
|
final block = parent as Block;
|
||||||
|
attributes = attributes.mergeAll(block.style);
|
||||||
|
}
|
||||||
|
delta.insert('\n', attributes.toJson());
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toPlainText() => '${super.toPlainText()}\n';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
final body = children.join(' → ');
|
||||||
|
final styleString = style.isNotEmpty ? ' $style' : '';
|
||||||
|
return '¶ $body ⏎$styleString';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void insert(int index, Object data, Style? style) {
|
||||||
|
if (data is Embeddable) {
|
||||||
|
// We do not check whether this line already has any children here as
|
||||||
|
// inserting an embed into a line with other text is acceptable from the
|
||||||
|
// Delta format perspective.
|
||||||
|
// We rely on heuristic rules to ensure that embeds occupy an entire line.
|
||||||
|
_insertSafe(index, data, style);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final text = data as String;
|
||||||
|
final lineBreak = text.indexOf('\n');
|
||||||
|
if (lineBreak < 0) {
|
||||||
|
_insertSafe(index, text, style);
|
||||||
|
// No need to update line or block format since those attributes can only
|
||||||
|
// be attached to `\n` character and we already know it's not present.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefix = text.substring(0, lineBreak);
|
||||||
|
_insertSafe(index, prefix, style);
|
||||||
|
if (prefix.isNotEmpty) {
|
||||||
|
index += prefix.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next line inherits our format.
|
||||||
|
final nextLine = _getNextLine(index);
|
||||||
|
|
||||||
|
// Reset our format and unwrap from a block if needed.
|
||||||
|
clearStyle();
|
||||||
|
if (parent is Block) {
|
||||||
|
_unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now we can apply new format and re-layout.
|
||||||
|
_format(style);
|
||||||
|
|
||||||
|
// Continue with remaining part.
|
||||||
|
final remain = text.substring(lineBreak + 1);
|
||||||
|
nextLine.insert(0, remain, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void retain(int index, int? len, Style? style) {
|
||||||
|
if (style == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final thisLength = length;
|
||||||
|
|
||||||
|
final local = math.min(thisLength - index, len!);
|
||||||
|
// If index is at newline character then this is a line/block style update.
|
||||||
|
final isLineFormat = (index + local == thisLength) && local == 1;
|
||||||
|
|
||||||
|
if (isLineFormat) {
|
||||||
|
assert(style.values.every((attr) => attr.scope == AttributeScope.BLOCK),
|
||||||
|
'It is not allowed to apply inline attributes to line itself.');
|
||||||
|
_format(style);
|
||||||
|
} else {
|
||||||
|
// Otherwise forward to children as it's an inline format update.
|
||||||
|
assert(style.values.every((attr) => attr.scope == AttributeScope.INLINE));
|
||||||
|
assert(index + local != thisLength);
|
||||||
|
super.retain(index, local, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
final remain = len - local;
|
||||||
|
if (remain > 0) {
|
||||||
|
assert(nextLine != null);
|
||||||
|
nextLine!.retain(0, remain, style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void delete(int index, int? len) {
|
||||||
|
final local = math.min(length - index, len!);
|
||||||
|
final isLFDeleted = index + local == length; // Line feed
|
||||||
|
if (isLFDeleted) {
|
||||||
|
// Our newline character deleted with all style information.
|
||||||
|
clearStyle();
|
||||||
|
if (local > 1) {
|
||||||
|
// Exclude newline character from delete range for children.
|
||||||
|
super.delete(index, local - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
super.delete(index, local);
|
||||||
|
}
|
||||||
|
|
||||||
|
final remaining = len - local;
|
||||||
|
if (remaining > 0) {
|
||||||
|
assert(nextLine != null);
|
||||||
|
nextLine!.delete(0, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLFDeleted && isNotEmpty) {
|
||||||
|
// Since we lost our line-break and still have child text nodes those must
|
||||||
|
// migrate to the next line.
|
||||||
|
|
||||||
|
// nextLine might have been unmounted since last assert so we need to
|
||||||
|
// check again we still have a line after us.
|
||||||
|
assert(nextLine != null);
|
||||||
|
|
||||||
|
// Move remaining children in this line to the next line so that all
|
||||||
|
// attributes of nextLine are preserved.
|
||||||
|
nextLine!.moveChildToNewParent(this);
|
||||||
|
moveChildToNewParent(nextLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLFDeleted) {
|
||||||
|
// Now we can remove this line.
|
||||||
|
final block = parent!; // remember reference before un-linking.
|
||||||
|
unlink();
|
||||||
|
block.adjust();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats this line.
|
||||||
|
void _format(Style? newStyle) {
|
||||||
|
if (newStyle == null || newStyle.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyStyle(newStyle);
|
||||||
|
final blockStyle = newStyle.getBlockExceptHeader();
|
||||||
|
if (blockStyle == null) {
|
||||||
|
return;
|
||||||
|
} // No block-level changes
|
||||||
|
|
||||||
|
if (parent is Block) {
|
||||||
|
final parentStyle = (parent as Block).style.getBlocksExceptHeader();
|
||||||
|
// Ensure that we're only unwrapping the block only if we unset a single
|
||||||
|
// block format in the `parentStyle` and there are no more block formats
|
||||||
|
// left to unset.
|
||||||
|
if (blockStyle.value == null &&
|
||||||
|
parentStyle.containsKey(blockStyle.key) &&
|
||||||
|
parentStyle.length == 1) {
|
||||||
|
_unwrap();
|
||||||
|
} else if (!const MapEquality()
|
||||||
|
.equals(newStyle.getBlocksExceptHeader(), parentStyle)) {
|
||||||
|
_unwrap();
|
||||||
|
// Block style now can contain multiple attributes
|
||||||
|
if (newStyle.attributes.keys
|
||||||
|
.any(Attribute.exclusiveBlockKeys.contains)) {
|
||||||
|
parentStyle.removeWhere(
|
||||||
|
(key, attr) => Attribute.exclusiveBlockKeys.contains(key));
|
||||||
|
}
|
||||||
|
parentStyle.removeWhere(
|
||||||
|
(key, attr) => newStyle?.attributes.keys.contains(key) ?? false);
|
||||||
|
final parentStyleToMerge = Style.attr(parentStyle);
|
||||||
|
newStyle = newStyle.mergeAll(parentStyleToMerge);
|
||||||
|
_applyBlockStyles(newStyle);
|
||||||
|
} // else the same style, no-op.
|
||||||
|
} else if (blockStyle.value != null) {
|
||||||
|
// Only wrap with a new block if this is not an unset
|
||||||
|
_applyBlockStyles(newStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyBlockStyles(Style newStyle) {
|
||||||
|
var block = Block();
|
||||||
|
for (final style in newStyle.getBlocksExceptHeader().values) {
|
||||||
|
block = block..applyAttribute(style);
|
||||||
|
}
|
||||||
|
_wrap(block);
|
||||||
|
block.adjust();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps this line with new parent [block].
|
||||||
|
///
|
||||||
|
/// This line can not be in a [Block] when this method is called.
|
||||||
|
void _wrap(Block block) {
|
||||||
|
assert(parent != null && parent is! Block);
|
||||||
|
insertAfter(block);
|
||||||
|
unlink();
|
||||||
|
block.add(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwraps this line from it's parent [Block].
|
||||||
|
///
|
||||||
|
/// This method asserts if current [parent] of this line is not a [Block].
|
||||||
|
void _unwrap() {
|
||||||
|
if (parent is! Block) {
|
||||||
|
throw ArgumentError('Invalid parent');
|
||||||
|
}
|
||||||
|
final block = parent as Block;
|
||||||
|
|
||||||
|
assert(block.children.contains(this));
|
||||||
|
|
||||||
|
if (isFirst) {
|
||||||
|
unlink();
|
||||||
|
block.insertBefore(this);
|
||||||
|
} else if (isLast) {
|
||||||
|
unlink();
|
||||||
|
block.insertAfter(this);
|
||||||
|
} else {
|
||||||
|
final before = block.clone() as Block;
|
||||||
|
block.insertBefore(before);
|
||||||
|
|
||||||
|
var child = block.first as Line;
|
||||||
|
while (child != this) {
|
||||||
|
child.unlink();
|
||||||
|
before.add(child);
|
||||||
|
child = block.first as Line;
|
||||||
|
}
|
||||||
|
unlink();
|
||||||
|
block.insertBefore(this);
|
||||||
|
}
|
||||||
|
block.adjust();
|
||||||
|
}
|
||||||
|
|
||||||
|
Line _getNextLine(int index) {
|
||||||
|
assert(index == 0 || (index > 0 && index < length));
|
||||||
|
|
||||||
|
final line = clone() as Line;
|
||||||
|
insertAfter(line);
|
||||||
|
if (index == length - 1) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
final query = queryChild(index, false);
|
||||||
|
while (!query.node!.isLast) {
|
||||||
|
final next = (last as Leaf)..unlink();
|
||||||
|
line.addFirst(next);
|
||||||
|
}
|
||||||
|
final child = query.node as Leaf;
|
||||||
|
final cut = child.splitAt(query.offset);
|
||||||
|
cut?.unlink();
|
||||||
|
line.addFirst(cut);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertSafe(int index, Object data, Style? style) {
|
||||||
|
assert(index == 0 || (index > 0 && index < length));
|
||||||
|
|
||||||
|
if (data is String) {
|
||||||
|
assert(!data.contains('\n'));
|
||||||
|
if (data.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
final child = Leaf(data);
|
||||||
|
add(child);
|
||||||
|
child.format(style);
|
||||||
|
} else {
|
||||||
|
final result = queryChild(index, true);
|
||||||
|
result.node!.insert(result.offset, data, style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns style for specified text range.
|
||||||
|
///
|
||||||
|
/// Only attributes applied to all characters within this range are
|
||||||
|
/// included in the result. Inline and line level attributes are
|
||||||
|
/// handled separately, e.g.:
|
||||||
|
///
|
||||||
|
/// - line attribute X is included in the result only if it exists for
|
||||||
|
/// every line within this range (partially included lines are counted).
|
||||||
|
/// - inline attribute X is included in the result only if it exists
|
||||||
|
/// for every character within this range (line-break characters excluded).
|
||||||
|
Style collectStyle(int offset, int len) {
|
||||||
|
final local = math.min(length - offset, len);
|
||||||
|
var result = Style();
|
||||||
|
final excluded = <Attribute>{};
|
||||||
|
|
||||||
|
void _handle(Style style) {
|
||||||
|
if (result.isEmpty) {
|
||||||
|
excluded.addAll(style.values);
|
||||||
|
} else {
|
||||||
|
for (final attr in result.values) {
|
||||||
|
if (!style.containsKey(attr.key)) {
|
||||||
|
excluded.add(attr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final remaining = style.removeAll(excluded);
|
||||||
|
result = result.removeAll(excluded);
|
||||||
|
result = result.mergeAll(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = queryChild(offset, true);
|
||||||
|
var node = data.node as Leaf?;
|
||||||
|
if (node != null) {
|
||||||
|
result = result.mergeAll(node.style);
|
||||||
|
var pos = node.length - data.offset;
|
||||||
|
while (!node!.isLast && pos < local) {
|
||||||
|
node = node.next as Leaf?;
|
||||||
|
_handle(node!.style);
|
||||||
|
pos += node.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.mergeAll(style);
|
||||||
|
if (parent is Block) {
|
||||||
|
final block = parent as Block;
|
||||||
|
result = result.mergeAll(block.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
final remaining = len - local;
|
||||||
|
if (remaining > 0) {
|
||||||
|
final rest = nextLine!.collectStyle(0, remaining);
|
||||||
|
_handle(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all styles for any character within the specified text range.
|
||||||
|
List<Style> collectAllStyles(int offset, int len) {
|
||||||
|
final local = math.min(length - offset, len);
|
||||||
|
final result = <Style>[];
|
||||||
|
|
||||||
|
final data = queryChild(offset, true);
|
||||||
|
var node = data.node as Leaf?;
|
||||||
|
if (node != null) {
|
||||||
|
result.add(node.style);
|
||||||
|
var pos = node.length - data.offset;
|
||||||
|
while (!node!.isLast && pos < local) {
|
||||||
|
node = node.next as Leaf?;
|
||||||
|
result.add(node!.style);
|
||||||
|
pos += node.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.add(style);
|
||||||
|
if (parent is Block) {
|
||||||
|
final block = parent as Block;
|
||||||
|
result.add(block.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
final remaining = len - local;
|
||||||
|
if (remaining > 0) {
|
||||||
|
final rest = nextLine!.collectAllStyles(0, remaining);
|
||||||
|
result.addAll(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,134 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import '../../quill_delta.dart';
|
||||||
|
import '../attribute.dart';
|
||||||
|
import '../style.dart';
|
||||||
|
import 'container.dart';
|
||||||
|
import 'line.dart';
|
||||||
|
|
||||||
|
/// An abstract node in a document tree.
|
||||||
|
///
|
||||||
|
/// Represents a segment of a Quill document with specified [offset]
|
||||||
|
/// and [length].
|
||||||
|
///
|
||||||
|
/// The [offset] property is relative to [parent]. See also [documentOffset]
|
||||||
|
/// which provides absolute offset of this node within the document.
|
||||||
|
///
|
||||||
|
/// The current parent node is exposed by the [parent] property.
|
||||||
|
abstract class Node extends LinkedListEntry<Node> {
|
||||||
|
/// Current parent of this node. May be null if this node is not mounted.
|
||||||
|
Container? parent;
|
||||||
|
|
||||||
|
Style get style => _style;
|
||||||
|
Style _style = Style();
|
||||||
|
|
||||||
|
/// Returns `true` if this node is the first node in the [parent] list.
|
||||||
|
bool get isFirst => list!.first == this;
|
||||||
|
|
||||||
|
/// Returns `true` if this node is the last node in the [parent] list.
|
||||||
|
bool get isLast => list!.last == this;
|
||||||
|
|
||||||
|
/// Length of this node in characters.
|
||||||
|
int get length;
|
||||||
|
|
||||||
|
Node clone() => newInstance()..applyStyle(style);
|
||||||
|
|
||||||
|
/// Offset in characters of this node relative to [parent] node.
|
||||||
|
///
|
||||||
|
/// To get offset of this node in the document see [documentOffset].
|
||||||
|
int get offset {
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
if (list == null || isFirst) {
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cur = this;
|
||||||
|
do {
|
||||||
|
cur = cur.previous!;
|
||||||
|
offset += cur.length;
|
||||||
|
} while (!cur.isFirst);
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offset in characters of this node in the document.
|
||||||
|
int get documentOffset {
|
||||||
|
if (parent == null) {
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
final parentOffset = (parent is! Root) ? parent!.documentOffset : 0;
|
||||||
|
return parentOffset + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if this node contains character at specified [offset] in
|
||||||
|
/// the document.
|
||||||
|
bool containsOffset(int offset) {
|
||||||
|
final o = documentOffset;
|
||||||
|
return o <= offset && offset < o + length;
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyAttribute(Attribute attribute) {
|
||||||
|
_style = _style.merge(attribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyStyle(Style value) {
|
||||||
|
_style = _style.mergeAll(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearStyle() {
|
||||||
|
_style = Style();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void insertBefore(Node entry) {
|
||||||
|
assert(entry.parent == null && parent != null);
|
||||||
|
entry.parent = parent;
|
||||||
|
super.insertBefore(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void insertAfter(Node entry) {
|
||||||
|
assert(entry.parent == null && parent != null);
|
||||||
|
entry.parent = parent;
|
||||||
|
super.insertAfter(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void unlink() {
|
||||||
|
assert(parent != null);
|
||||||
|
parent = null;
|
||||||
|
super.unlink();
|
||||||
|
}
|
||||||
|
|
||||||
|
void adjust() {/* no-op */}
|
||||||
|
|
||||||
|
/// abstract methods begin
|
||||||
|
|
||||||
|
Node newInstance();
|
||||||
|
|
||||||
|
String toPlainText();
|
||||||
|
|
||||||
|
Delta toDelta();
|
||||||
|
|
||||||
|
void insert(int index, Object data, Style? style);
|
||||||
|
|
||||||
|
void retain(int index, int? len, Style? style);
|
||||||
|
|
||||||
|
void delete(int index, int? len);
|
||||||
|
|
||||||
|
/// abstract methods end
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root node of document tree.
|
||||||
|
class Root extends Container<Container<Node?>> {
|
||||||
|
@override
|
||||||
|
Node newInstance() => Root();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Container<Node?> get defaultChild => Line();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta toDelta() => children
|
||||||
|
.map((child) => child.toDelta())
|
||||||
|
.fold(Delta(), (a, b) => a.concat(b));
|
||||||
|
}
|
128
app_flowy/packages/editor/lib/src/models/documents/style.dart
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:quiver/core.dart';
|
||||||
|
|
||||||
|
import 'attribute.dart';
|
||||||
|
|
||||||
|
/* Collection of style attributes */
|
||||||
|
class Style {
|
||||||
|
Style() : _attributes = <String, Attribute>{};
|
||||||
|
|
||||||
|
Style.attr(this._attributes);
|
||||||
|
|
||||||
|
final Map<String, Attribute> _attributes;
|
||||||
|
|
||||||
|
static Style fromJson(Map<String, dynamic>? attributes) {
|
||||||
|
if (attributes == null) {
|
||||||
|
return Style();
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = attributes.map((key, dynamic value) {
|
||||||
|
final attr = Attribute.fromKeyValue(key, value);
|
||||||
|
return MapEntry<String, Attribute>(
|
||||||
|
key, attr ?? Attribute(key, AttributeScope.IGNORE, value));
|
||||||
|
});
|
||||||
|
return Style.attr(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? toJson() => _attributes.isEmpty
|
||||||
|
? null
|
||||||
|
: _attributes.map<String, dynamic>((_, attribute) =>
|
||||||
|
MapEntry<String, dynamic>(attribute.key, attribute.value));
|
||||||
|
|
||||||
|
Iterable<String> get keys => _attributes.keys;
|
||||||
|
|
||||||
|
Iterable<Attribute> get values => _attributes.values.sorted(
|
||||||
|
(a, b) => Attribute.getRegistryOrder(a) - Attribute.getRegistryOrder(b));
|
||||||
|
|
||||||
|
Map<String, Attribute> get attributes => _attributes;
|
||||||
|
|
||||||
|
bool get isEmpty => _attributes.isEmpty;
|
||||||
|
|
||||||
|
bool get isNotEmpty => _attributes.isNotEmpty;
|
||||||
|
|
||||||
|
bool get isInline => isNotEmpty && values.every((item) => item.isInline);
|
||||||
|
|
||||||
|
bool get isIgnored =>
|
||||||
|
isNotEmpty && values.every((item) => item.scope == AttributeScope.IGNORE);
|
||||||
|
|
||||||
|
Attribute get single => _attributes.values.single;
|
||||||
|
|
||||||
|
bool containsKey(String key) => _attributes.containsKey(key);
|
||||||
|
|
||||||
|
Attribute? getBlockExceptHeader() {
|
||||||
|
for (final val in values) {
|
||||||
|
if (val.isBlockExceptHeader && val.value != null) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final val in values) {
|
||||||
|
if (val.isBlockExceptHeader) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Attribute> getBlocksExceptHeader() {
|
||||||
|
final m = <String, Attribute>{};
|
||||||
|
attributes.forEach((key, value) {
|
||||||
|
if (Attribute.blockKeysExceptHeader.contains(key)) {
|
||||||
|
m[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
Style merge(Attribute attribute) {
|
||||||
|
final merged = Map<String, Attribute>.from(_attributes);
|
||||||
|
if (attribute.value == null) {
|
||||||
|
merged.remove(attribute.key);
|
||||||
|
} else {
|
||||||
|
merged[attribute.key] = attribute;
|
||||||
|
}
|
||||||
|
return Style.attr(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
Style mergeAll(Style other) {
|
||||||
|
var result = Style.attr(_attributes);
|
||||||
|
for (final attribute in other.values) {
|
||||||
|
result = result.merge(attribute);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Style removeAll(Set<Attribute> attributes) {
|
||||||
|
final merged = Map<String, Attribute>.from(_attributes);
|
||||||
|
attributes.map((item) => item.key).forEach(merged.remove);
|
||||||
|
return Style.attr(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
Style put(Attribute attribute) {
|
||||||
|
final m = Map<String, Attribute>.from(attributes);
|
||||||
|
m[attribute.key] = attribute;
|
||||||
|
return Style.attr(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (other is! Style) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final typedOther = other;
|
||||||
|
const eq = MapEquality<String, Attribute>();
|
||||||
|
return eq.equals(_attributes, typedOther._attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
final hashes =
|
||||||
|
_attributes.entries.map((entry) => hash2(entry.key, entry.value));
|
||||||
|
return hashObjects(hashes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => "{${_attributes.values.join(', ')}}";
|
||||||
|
}
|
803
app_flowy/packages/editor/lib/src/models/quill_delta.dart
Normal file
|
@ -0,0 +1,803 @@
|
||||||
|
// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this
|
||||||
|
// source code is governed by a BSD-style license that can be found in the
|
||||||
|
// LICENSE file.
|
||||||
|
|
||||||
|
/// Implementation of Quill Delta format in Dart.
|
||||||
|
library quill_delta;
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:diff_match_patch/diff_match_patch.dart' as dmp;
|
||||||
|
import 'package:quiver/core.dart';
|
||||||
|
|
||||||
|
const _attributeEquality = DeepCollectionEquality();
|
||||||
|
const _valueEquality = DeepCollectionEquality();
|
||||||
|
|
||||||
|
/// Decoder function to convert raw `data` object into a user-defined data type.
|
||||||
|
///
|
||||||
|
/// Useful with embedded content.
|
||||||
|
typedef DataDecoder = Object? Function(Object data);
|
||||||
|
|
||||||
|
/// Default data decoder which simply passes through the original value.
|
||||||
|
Object? _passThroughDataDecoder(Object? data) => data;
|
||||||
|
|
||||||
|
/// Operation performed on a rich-text document.
|
||||||
|
class Operation {
|
||||||
|
Operation._(this.key, this.length, this.data, Map? attributes)
|
||||||
|
: assert(_validKeys.contains(key), 'Invalid operation key "$key".'),
|
||||||
|
assert(() {
|
||||||
|
if (key != Operation.insertKey) return true;
|
||||||
|
return data is String ? data.length == length : length == 1;
|
||||||
|
}(), 'Length of insert operation must be equal to the data length.'),
|
||||||
|
_attributes =
|
||||||
|
attributes != null ? Map<String, dynamic>.from(attributes) : null;
|
||||||
|
|
||||||
|
/// Creates operation which deletes [length] of characters.
|
||||||
|
factory Operation.delete(int length) =>
|
||||||
|
Operation._(Operation.deleteKey, length, '', null);
|
||||||
|
|
||||||
|
/// Creates operation which inserts [text] with optional [attributes].
|
||||||
|
factory Operation.insert(dynamic data, [Map<String, dynamic>? attributes]) =>
|
||||||
|
Operation._(Operation.insertKey, data is String ? data.length : 1, data,
|
||||||
|
attributes);
|
||||||
|
|
||||||
|
/// Creates operation which retains [length] of characters and optionally
|
||||||
|
/// applies attributes.
|
||||||
|
factory Operation.retain(int? length, [Map<String, dynamic>? attributes]) =>
|
||||||
|
Operation._(Operation.retainKey, length, '', attributes);
|
||||||
|
|
||||||
|
/// Key of insert operations.
|
||||||
|
static const String insertKey = 'insert';
|
||||||
|
|
||||||
|
/// Key of delete operations.
|
||||||
|
static const String deleteKey = 'delete';
|
||||||
|
|
||||||
|
/// Key of retain operations.
|
||||||
|
static const String retainKey = 'retain';
|
||||||
|
|
||||||
|
/// Key of attributes collection.
|
||||||
|
static const String attributesKey = 'attributes';
|
||||||
|
|
||||||
|
static const List<String> _validKeys = [insertKey, deleteKey, retainKey];
|
||||||
|
|
||||||
|
/// Key of this operation, can be "insert", "delete" or "retain".
|
||||||
|
final String key;
|
||||||
|
|
||||||
|
/// Length of this operation.
|
||||||
|
final int? length;
|
||||||
|
|
||||||
|
/// Payload of "insert" operation, for other types is set to empty string.
|
||||||
|
final Object? data;
|
||||||
|
|
||||||
|
/// Rich-text attributes set by this operation, can be `null`.
|
||||||
|
Map<String, dynamic>? get attributes =>
|
||||||
|
_attributes == null ? null : Map<String, dynamic>.from(_attributes!);
|
||||||
|
final Map<String, dynamic>? _attributes;
|
||||||
|
|
||||||
|
/// Creates new [Operation] from JSON payload.
|
||||||
|
///
|
||||||
|
/// If `dataDecoder` parameter is not null then it is used to additionally
|
||||||
|
/// decode the operation's data object. Only applied to insert operations.
|
||||||
|
static Operation fromJson(Map data, {DataDecoder? dataDecoder}) {
|
||||||
|
dataDecoder ??= _passThroughDataDecoder;
|
||||||
|
final map = Map<String, dynamic>.from(data);
|
||||||
|
if (map.containsKey(Operation.insertKey)) {
|
||||||
|
final data = dataDecoder(map[Operation.insertKey]);
|
||||||
|
final dataLength = data is String ? data.length : 1;
|
||||||
|
return Operation._(
|
||||||
|
Operation.insertKey, dataLength, data, map[Operation.attributesKey]);
|
||||||
|
} else if (map.containsKey(Operation.deleteKey)) {
|
||||||
|
final int? length = map[Operation.deleteKey];
|
||||||
|
return Operation._(Operation.deleteKey, length, '', null);
|
||||||
|
} else if (map.containsKey(Operation.retainKey)) {
|
||||||
|
final int? length = map[Operation.retainKey];
|
||||||
|
return Operation._(
|
||||||
|
Operation.retainKey, length, '', map[Operation.attributesKey]);
|
||||||
|
}
|
||||||
|
throw ArgumentError.value(data, 'Invalid data for Delta operation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns JSON-serializable representation of this operation.
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = {key: value};
|
||||||
|
if (_attributes != null) json[Operation.attributesKey] = attributes;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns value of this operation.
|
||||||
|
///
|
||||||
|
/// For insert operations this returns text, for delete and retain - length.
|
||||||
|
dynamic get value => (key == Operation.insertKey) ? data : length;
|
||||||
|
|
||||||
|
/// Returns `true` if this is a delete operation.
|
||||||
|
bool get isDelete => key == Operation.deleteKey;
|
||||||
|
|
||||||
|
/// Returns `true` if this is an insert operation.
|
||||||
|
bool get isInsert => key == Operation.insertKey;
|
||||||
|
|
||||||
|
/// Returns `true` if this is a retain operation.
|
||||||
|
bool get isRetain => key == Operation.retainKey;
|
||||||
|
|
||||||
|
/// Returns `true` if this operation has no attributes, e.g. is plain text.
|
||||||
|
bool get isPlain => _attributes == null || _attributes!.isEmpty;
|
||||||
|
|
||||||
|
/// Returns `true` if this operation sets at least one attribute.
|
||||||
|
bool get isNotPlain => !isPlain;
|
||||||
|
|
||||||
|
/// Returns `true` is this operation is empty.
|
||||||
|
///
|
||||||
|
/// An operation is considered empty if its [length] is equal to `0`.
|
||||||
|
bool get isEmpty => length == 0;
|
||||||
|
|
||||||
|
/// Returns `true` is this operation is not empty.
|
||||||
|
bool get isNotEmpty => length! > 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other is! Operation) return false;
|
||||||
|
final typedOther = other;
|
||||||
|
return key == typedOther.key &&
|
||||||
|
length == typedOther.length &&
|
||||||
|
_valueEquality.equals(data, typedOther.data) &&
|
||||||
|
hasSameAttributes(typedOther);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if this operation has attribute specified by [name].
|
||||||
|
bool hasAttribute(String name) =>
|
||||||
|
isNotPlain && _attributes!.containsKey(name);
|
||||||
|
|
||||||
|
/// Returns `true` if [other] operation has the same attributes as this one.
|
||||||
|
bool hasSameAttributes(Operation other) {
|
||||||
|
return _attributeEquality.equals(_attributes, other._attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
if (_attributes != null && _attributes!.isNotEmpty) {
|
||||||
|
final attrsHash =
|
||||||
|
hashObjects(_attributes!.entries.map((e) => hash2(e.key, e.value)));
|
||||||
|
return hash3(key, value, attrsHash);
|
||||||
|
}
|
||||||
|
return hash2(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
final attr = attributes == null ? '' : ' + $attributes';
|
||||||
|
final text = isInsert
|
||||||
|
? (data is String
|
||||||
|
? (data as String).replaceAll('\n', '⏎')
|
||||||
|
: data.toString())
|
||||||
|
: '$length';
|
||||||
|
return '$key⟨ $text ⟩$attr';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delta represents a document or a modification of a document as a sequence of
|
||||||
|
/// insert, delete and retain operations.
|
||||||
|
///
|
||||||
|
/// Delta consisting of only "insert" operations is usually referred to as
|
||||||
|
/// "document delta". When delta includes also "retain" or "delete" operations
|
||||||
|
/// it is a "change delta".
|
||||||
|
class Delta {
|
||||||
|
/// Creates new empty [Delta].
|
||||||
|
factory Delta() => Delta._(<Operation>[]);
|
||||||
|
|
||||||
|
Delta._(List<Operation> operations) : _operations = operations;
|
||||||
|
|
||||||
|
/// Creates new [Delta] from [other].
|
||||||
|
factory Delta.from(Delta other) =>
|
||||||
|
Delta._(List<Operation>.from(other._operations));
|
||||||
|
|
||||||
|
// Placeholder char for embed in diff()
|
||||||
|
static final String _kNullCharacter = String.fromCharCode(0);
|
||||||
|
|
||||||
|
/// Transforms two attribute sets.
|
||||||
|
static Map<String, dynamic>? transformAttributes(
|
||||||
|
Map<String, dynamic>? a, Map<String, dynamic>? b, bool priority) {
|
||||||
|
if (a == null) return b;
|
||||||
|
if (b == null) return null;
|
||||||
|
|
||||||
|
if (!priority) return b;
|
||||||
|
|
||||||
|
final result = b.keys.fold<Map<String, dynamic>>({}, (attributes, key) {
|
||||||
|
if (!a.containsKey(key)) attributes[key] = b[key];
|
||||||
|
return attributes;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.isEmpty ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composes two attribute sets.
|
||||||
|
static Map<String, dynamic>? composeAttributes(
|
||||||
|
Map<String, dynamic>? a, Map<String, dynamic>? b,
|
||||||
|
{bool keepNull = false}) {
|
||||||
|
a ??= const {};
|
||||||
|
b ??= const {};
|
||||||
|
|
||||||
|
final result = Map<String, dynamic>.from(a)..addAll(b);
|
||||||
|
final keys = result.keys.toList(growable: false);
|
||||||
|
|
||||||
|
if (!keepNull) {
|
||||||
|
for (final key in keys) {
|
||||||
|
if (result[key] == null) result.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.isEmpty ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
///get anti-attr result base on base
|
||||||
|
static Map<String, dynamic> invertAttributes(
|
||||||
|
Map<String, dynamic>? attr, Map<String, dynamic>? base) {
|
||||||
|
attr ??= const {};
|
||||||
|
base ??= const {};
|
||||||
|
|
||||||
|
final baseInverted = base.keys.fold({}, (dynamic memo, key) {
|
||||||
|
if (base![key] != attr![key] && attr.containsKey(key)) {
|
||||||
|
memo[key] = base[key];
|
||||||
|
}
|
||||||
|
return memo;
|
||||||
|
});
|
||||||
|
|
||||||
|
final inverted =
|
||||||
|
Map<String, dynamic>.from(attr.keys.fold(baseInverted, (memo, key) {
|
||||||
|
if (base![key] != attr![key] && !base.containsKey(key)) {
|
||||||
|
memo[key] = null;
|
||||||
|
}
|
||||||
|
return memo;
|
||||||
|
}));
|
||||||
|
return inverted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns diff between two attribute sets
|
||||||
|
static Map<String, dynamic>? diffAttributes(
|
||||||
|
Map<String, dynamic>? a, Map<String, dynamic>? b) {
|
||||||
|
a ??= const {};
|
||||||
|
b ??= const {};
|
||||||
|
|
||||||
|
final attributes = <String, dynamic>{};
|
||||||
|
(a.keys.toList()..addAll(b.keys)).forEach((key) {
|
||||||
|
if (a![key] != b![key]) {
|
||||||
|
attributes[key] = b.containsKey(key) ? b[key] : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return attributes.keys.isNotEmpty ? attributes : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Operation> _operations;
|
||||||
|
|
||||||
|
int _modificationCount = 0;
|
||||||
|
|
||||||
|
/// Creates [Delta] from de-serialized JSON representation.
|
||||||
|
///
|
||||||
|
/// If `dataDecoder` parameter is not null then it is used to additionally
|
||||||
|
/// decode the operation's data object. Only applied to insert operations.
|
||||||
|
static Delta fromJson(List data, {DataDecoder? dataDecoder}) {
|
||||||
|
return Delta._(data
|
||||||
|
.map((op) => Operation.fromJson(op, dataDecoder: dataDecoder))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns list of operations in this delta.
|
||||||
|
List<Operation> toList() => List.from(_operations);
|
||||||
|
|
||||||
|
/// Returns JSON-serializable version of this delta.
|
||||||
|
List toJson() => toList().map((operation) => operation.toJson()).toList();
|
||||||
|
|
||||||
|
/// Returns `true` if this delta is empty.
|
||||||
|
bool get isEmpty => _operations.isEmpty;
|
||||||
|
|
||||||
|
/// Returns `true` if this delta is not empty.
|
||||||
|
bool get isNotEmpty => _operations.isNotEmpty;
|
||||||
|
|
||||||
|
/// Returns number of operations in this delta.
|
||||||
|
int get length => _operations.length;
|
||||||
|
|
||||||
|
/// Returns [Operation] at specified [index] in this delta.
|
||||||
|
Operation operator [](int index) => _operations[index];
|
||||||
|
|
||||||
|
/// Returns [Operation] at specified [index] in this delta.
|
||||||
|
Operation elementAt(int index) => _operations.elementAt(index);
|
||||||
|
|
||||||
|
/// Returns the first [Operation] in this delta.
|
||||||
|
Operation get first => _operations.first;
|
||||||
|
|
||||||
|
/// Returns the last [Operation] in this delta.
|
||||||
|
Operation get last => _operations.last;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(dynamic other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other is! Delta) return false;
|
||||||
|
final typedOther = other;
|
||||||
|
const comparator = ListEquality<Operation>(DefaultEquality<Operation>());
|
||||||
|
return comparator.equals(_operations, typedOther._operations);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => hashObjects(_operations);
|
||||||
|
|
||||||
|
/// Retain [count] of characters from current position.
|
||||||
|
void retain(int count, [Map<String, dynamic>? attributes]) {
|
||||||
|
assert(count >= 0);
|
||||||
|
if (count == 0) return; // no-op
|
||||||
|
push(Operation.retain(count, attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert [data] at current position.
|
||||||
|
void insert(dynamic data, [Map<String, dynamic>? attributes]) {
|
||||||
|
if (data is String && data.isEmpty) return; // no-op
|
||||||
|
push(Operation.insert(data, attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete [count] characters from current position.
|
||||||
|
void delete(int count) {
|
||||||
|
assert(count >= 0);
|
||||||
|
if (count == 0) return;
|
||||||
|
push(Operation.delete(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _mergeWithTail(Operation operation) {
|
||||||
|
assert(isNotEmpty);
|
||||||
|
assert(last.key == operation.key);
|
||||||
|
assert(operation.data is String && last.data is String);
|
||||||
|
|
||||||
|
final length = operation.length! + last.length!;
|
||||||
|
final lastText = last.data as String;
|
||||||
|
final opText = operation.data as String;
|
||||||
|
final resultText = lastText + opText;
|
||||||
|
final index = _operations.length;
|
||||||
|
_operations.replaceRange(index - 1, index, [
|
||||||
|
Operation._(operation.key, length, resultText, operation.attributes),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes new operation into this delta.
|
||||||
|
///
|
||||||
|
/// Performs compaction by composing [operation] with current tail operation
|
||||||
|
/// of this delta, when possible. For instance, if current tail is
|
||||||
|
/// `insert('abc')` and pushed operation is `insert('123')` then existing
|
||||||
|
/// tail is replaced with `insert('abc123')` - a compound result of the two
|
||||||
|
/// operations.
|
||||||
|
void push(Operation operation) {
|
||||||
|
if (operation.isEmpty) return;
|
||||||
|
|
||||||
|
var index = _operations.length;
|
||||||
|
final lastOp = _operations.isNotEmpty ? _operations.last : null;
|
||||||
|
if (lastOp != null) {
|
||||||
|
if (lastOp.isDelete && operation.isDelete) {
|
||||||
|
_mergeWithTail(operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastOp.isDelete && operation.isInsert) {
|
||||||
|
index -= 1; // Always insert before deleting
|
||||||
|
final nLastOp = (index > 0) ? _operations.elementAt(index - 1) : null;
|
||||||
|
if (nLastOp == null) {
|
||||||
|
_operations.insert(0, operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastOp.isInsert && operation.isInsert) {
|
||||||
|
if (lastOp.hasSameAttributes(operation) &&
|
||||||
|
operation.data is String &&
|
||||||
|
lastOp.data is String) {
|
||||||
|
_mergeWithTail(operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastOp.isRetain && operation.isRetain) {
|
||||||
|
if (lastOp.hasSameAttributes(operation)) {
|
||||||
|
_mergeWithTail(operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index == _operations.length) {
|
||||||
|
_operations.add(operation);
|
||||||
|
} else {
|
||||||
|
final opAtIndex = _operations.elementAt(index);
|
||||||
|
_operations.replaceRange(index, index + 1, [operation, opAtIndex]);
|
||||||
|
}
|
||||||
|
_modificationCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composes next operation from [thisIter] and [otherIter].
|
||||||
|
///
|
||||||
|
/// Returns new operation or `null` if operations from [thisIter] and
|
||||||
|
/// [otherIter] nullify each other. For instance, for the pair `insert('abc')`
|
||||||
|
/// and `delete(3)` composition result would be empty string.
|
||||||
|
Operation? _composeOperation(
|
||||||
|
DeltaIterator thisIter, DeltaIterator otherIter) {
|
||||||
|
if (otherIter.isNextInsert) return otherIter.next();
|
||||||
|
if (thisIter.isNextDelete) return thisIter.next();
|
||||||
|
|
||||||
|
final length = math.min(thisIter.peekLength(), otherIter.peekLength());
|
||||||
|
final thisOp = thisIter.next(length);
|
||||||
|
final otherOp = otherIter.next(length);
|
||||||
|
assert(thisOp.length == otherOp.length);
|
||||||
|
|
||||||
|
if (otherOp.isRetain) {
|
||||||
|
final attributes = composeAttributes(
|
||||||
|
thisOp.attributes,
|
||||||
|
otherOp.attributes,
|
||||||
|
keepNull: thisOp.isRetain,
|
||||||
|
);
|
||||||
|
if (thisOp.isRetain) {
|
||||||
|
return Operation.retain(thisOp.length, attributes);
|
||||||
|
} else if (thisOp.isInsert) {
|
||||||
|
return Operation.insert(thisOp.data, attributes);
|
||||||
|
} else {
|
||||||
|
throw StateError('Unreachable');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// otherOp == delete && thisOp in [retain, insert]
|
||||||
|
assert(otherOp.isDelete);
|
||||||
|
if (thisOp.isRetain) return otherOp;
|
||||||
|
assert(thisOp.isInsert);
|
||||||
|
// otherOp(delete) + thisOp(insert) => null
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composes this delta with [other] and returns new [Delta].
|
||||||
|
///
|
||||||
|
/// It is not required for this and [other] delta to represent a document
|
||||||
|
/// delta (consisting only of insert operations).
|
||||||
|
Delta compose(Delta other) {
|
||||||
|
final result = Delta();
|
||||||
|
final thisIter = DeltaIterator(this);
|
||||||
|
final otherIter = DeltaIterator(other);
|
||||||
|
|
||||||
|
while (thisIter.hasNext || otherIter.hasNext) {
|
||||||
|
final newOp = _composeOperation(thisIter, otherIter);
|
||||||
|
if (newOp != null) result.push(newOp);
|
||||||
|
}
|
||||||
|
return result..trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new lazy Iterable with elements that are created by calling
|
||||||
|
/// f on each element of this Iterable in iteration order.
|
||||||
|
///
|
||||||
|
/// Convenience method
|
||||||
|
Iterable<T> map<T>(T Function(Operation) f) {
|
||||||
|
return _operations.map<T>(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [Delta] containing differences between 2 [Delta]s.
|
||||||
|
/// If [cleanupSemantic] is `true` (default), applies the following:
|
||||||
|
///
|
||||||
|
/// The diff of "mouse" and "sofas" is
|
||||||
|
/// [delete(1), insert("s"), retain(1),
|
||||||
|
/// delete("u"), insert("fa"), retain(1), delete(1)].
|
||||||
|
/// While this is the optimum diff, it is difficult for humans to understand.
|
||||||
|
/// Semantic cleanup rewrites the diff,
|
||||||
|
/// expanding it into a more intelligible format.
|
||||||
|
/// The above example would become: [(-1, "mouse"), (1, "sofas")].
|
||||||
|
/// (source: https://github.com/google/diff-match-patch/wiki/API)
|
||||||
|
///
|
||||||
|
/// Useful when one wishes to display difference between 2 documents
|
||||||
|
Delta diff(Delta other, {bool cleanupSemantic = true}) {
|
||||||
|
if (_operations.equals(other._operations)) {
|
||||||
|
return Delta();
|
||||||
|
}
|
||||||
|
final stringThis = map((op) {
|
||||||
|
if (op.isInsert) {
|
||||||
|
return op.data is String ? op.data : _kNullCharacter;
|
||||||
|
}
|
||||||
|
final prep = this == other ? 'on' : 'with';
|
||||||
|
throw ArgumentError('diff() call $prep non-document');
|
||||||
|
}).join();
|
||||||
|
final stringOther = other.map((op) {
|
||||||
|
if (op.isInsert) {
|
||||||
|
return op.data is String ? op.data : _kNullCharacter;
|
||||||
|
}
|
||||||
|
final prep = this == other ? 'on' : 'with';
|
||||||
|
throw ArgumentError('diff() call $prep non-document');
|
||||||
|
}).join();
|
||||||
|
|
||||||
|
final retDelta = Delta();
|
||||||
|
final diffResult = dmp.diff(stringThis, stringOther);
|
||||||
|
if (cleanupSemantic) {
|
||||||
|
dmp.DiffMatchPatch().diffCleanupSemantic(diffResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
final thisIter = DeltaIterator(this);
|
||||||
|
final otherIter = DeltaIterator(other);
|
||||||
|
|
||||||
|
diffResult.forEach((component) {
|
||||||
|
var length = component.text.length;
|
||||||
|
while (length > 0) {
|
||||||
|
var opLength = 0;
|
||||||
|
switch (component.operation) {
|
||||||
|
case dmp.DIFF_INSERT:
|
||||||
|
opLength = math.min(otherIter.peekLength(), length);
|
||||||
|
retDelta.push(otherIter.next(opLength));
|
||||||
|
break;
|
||||||
|
case dmp.DIFF_DELETE:
|
||||||
|
opLength = math.min(length, thisIter.peekLength());
|
||||||
|
thisIter.next(opLength);
|
||||||
|
retDelta.delete(opLength);
|
||||||
|
break;
|
||||||
|
case dmp.DIFF_EQUAL:
|
||||||
|
opLength = math.min(
|
||||||
|
math.min(thisIter.peekLength(), otherIter.peekLength()),
|
||||||
|
length,
|
||||||
|
);
|
||||||
|
final thisOp = thisIter.next(opLength);
|
||||||
|
final otherOp = otherIter.next(opLength);
|
||||||
|
if (thisOp.data == otherOp.data) {
|
||||||
|
retDelta.retain(
|
||||||
|
opLength,
|
||||||
|
diffAttributes(thisOp.attributes, otherOp.attributes),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
retDelta
|
||||||
|
..push(otherOp)
|
||||||
|
..delete(opLength);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
length -= opLength;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return retDelta..trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transforms next operation from [otherIter] against next operation in
|
||||||
|
/// [thisIter].
|
||||||
|
///
|
||||||
|
/// Returns `null` if both operations nullify each other.
|
||||||
|
Operation? _transformOperation(
|
||||||
|
DeltaIterator thisIter, DeltaIterator otherIter, bool priority) {
|
||||||
|
if (thisIter.isNextInsert && (priority || !otherIter.isNextInsert)) {
|
||||||
|
return Operation.retain(thisIter.next().length);
|
||||||
|
} else if (otherIter.isNextInsert) {
|
||||||
|
return otherIter.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
final length = math.min(thisIter.peekLength(), otherIter.peekLength());
|
||||||
|
final thisOp = thisIter.next(length);
|
||||||
|
final otherOp = otherIter.next(length);
|
||||||
|
assert(thisOp.length == otherOp.length);
|
||||||
|
|
||||||
|
// At this point only delete and retain operations are possible.
|
||||||
|
if (thisOp.isDelete) {
|
||||||
|
// otherOp is either delete or retain, so they nullify each other.
|
||||||
|
return null;
|
||||||
|
} else if (otherOp.isDelete) {
|
||||||
|
return otherOp;
|
||||||
|
} else {
|
||||||
|
// Retain otherOp which is either retain or insert.
|
||||||
|
return Operation.retain(
|
||||||
|
length,
|
||||||
|
transformAttributes(thisOp.attributes, otherOp.attributes, priority),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transforms [other] delta against operations in this delta.
|
||||||
|
Delta transform(Delta other, bool priority) {
|
||||||
|
final result = Delta();
|
||||||
|
final thisIter = DeltaIterator(this);
|
||||||
|
final otherIter = DeltaIterator(other);
|
||||||
|
|
||||||
|
while (thisIter.hasNext || otherIter.hasNext) {
|
||||||
|
final newOp = _transformOperation(thisIter, otherIter, priority);
|
||||||
|
if (newOp != null) result.push(newOp);
|
||||||
|
}
|
||||||
|
return result..trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes trailing retain operation with empty attributes, if present.
|
||||||
|
void trim() {
|
||||||
|
if (isNotEmpty) {
|
||||||
|
final last = _operations.last;
|
||||||
|
if (last.isRetain && last.isPlain) _operations.removeLast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concatenates [other] with this delta and returns the result.
|
||||||
|
Delta concat(Delta other) {
|
||||||
|
final result = Delta.from(this);
|
||||||
|
if (other.isNotEmpty) {
|
||||||
|
// In case first operation of other can be merged with last operation in
|
||||||
|
// our list.
|
||||||
|
result.push(other._operations.first);
|
||||||
|
result._operations.addAll(other._operations.sublist(1));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverts this delta against [base].
|
||||||
|
///
|
||||||
|
/// Returns new delta which negates effect of this delta when applied to
|
||||||
|
/// [base]. This is an equivalent of "undo" operation on deltas.
|
||||||
|
Delta invert(Delta base) {
|
||||||
|
final inverted = Delta();
|
||||||
|
if (base.isEmpty) return inverted;
|
||||||
|
|
||||||
|
var baseIndex = 0;
|
||||||
|
for (final op in _operations) {
|
||||||
|
if (op.isInsert) {
|
||||||
|
inverted.delete(op.length!);
|
||||||
|
} else if (op.isRetain && op.isPlain) {
|
||||||
|
inverted.retain(op.length!);
|
||||||
|
baseIndex += op.length!;
|
||||||
|
} else if (op.isDelete || (op.isRetain && op.isNotPlain)) {
|
||||||
|
final length = op.length!;
|
||||||
|
final sliceDelta = base.slice(baseIndex, baseIndex + length);
|
||||||
|
sliceDelta.toList().forEach((baseOp) {
|
||||||
|
if (op.isDelete) {
|
||||||
|
inverted.push(baseOp);
|
||||||
|
} else if (op.isRetain && op.isNotPlain) {
|
||||||
|
final invertAttr =
|
||||||
|
invertAttributes(op.attributes, baseOp.attributes);
|
||||||
|
inverted.retain(
|
||||||
|
baseOp.length!, invertAttr.isEmpty ? null : invertAttr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
baseIndex += length;
|
||||||
|
} else {
|
||||||
|
throw StateError('Unreachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inverted.trim();
|
||||||
|
return inverted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns slice of this delta from [start] index (inclusive) to [end]
|
||||||
|
/// (exclusive).
|
||||||
|
Delta slice(int start, [int? end]) {
|
||||||
|
final delta = Delta();
|
||||||
|
var index = 0;
|
||||||
|
final opIterator = DeltaIterator(this);
|
||||||
|
|
||||||
|
final actualEnd = end ?? DeltaIterator.maxLength;
|
||||||
|
|
||||||
|
while (index < actualEnd && opIterator.hasNext) {
|
||||||
|
Operation op;
|
||||||
|
if (index < start) {
|
||||||
|
op = opIterator.next(start - index);
|
||||||
|
} else {
|
||||||
|
op = opIterator.next(actualEnd - index);
|
||||||
|
delta.push(op);
|
||||||
|
}
|
||||||
|
index += op.length!;
|
||||||
|
}
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transforms [index] against this delta.
|
||||||
|
///
|
||||||
|
/// Any "delete" operation before specified [index] shifts it backward, as
|
||||||
|
/// well as any "insert" operation shifts it forward.
|
||||||
|
///
|
||||||
|
/// The [force] argument is used to resolve scenarios when there is an
|
||||||
|
/// insert operation at the same position as [index]. If [force] is set to
|
||||||
|
/// `true` (default) then position is forced to shift forward, otherwise
|
||||||
|
/// position stays at the same index. In other words setting [force] to
|
||||||
|
/// `false` gives higher priority to the transformed position.
|
||||||
|
///
|
||||||
|
/// Useful to adjust caret or selection positions.
|
||||||
|
int transformPosition(int index, {bool force = true}) {
|
||||||
|
final iter = DeltaIterator(this);
|
||||||
|
var offset = 0;
|
||||||
|
while (iter.hasNext && offset <= index) {
|
||||||
|
final op = iter.next();
|
||||||
|
if (op.isDelete) {
|
||||||
|
index -= math.min(op.length!, index - offset);
|
||||||
|
continue;
|
||||||
|
} else if (op.isInsert && (offset < index || force)) {
|
||||||
|
index += op.length!;
|
||||||
|
}
|
||||||
|
offset += op.length!;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _operations.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specialized iterator for [Delta]s.
|
||||||
|
class DeltaIterator {
|
||||||
|
DeltaIterator(this.delta) : _modificationCount = delta._modificationCount;
|
||||||
|
|
||||||
|
static const int maxLength = 1073741824;
|
||||||
|
|
||||||
|
final Delta delta;
|
||||||
|
final int _modificationCount;
|
||||||
|
int _index = 0;
|
||||||
|
int _offset = 0;
|
||||||
|
|
||||||
|
bool get isNextInsert => nextOperationKey == Operation.insertKey;
|
||||||
|
|
||||||
|
bool get isNextDelete => nextOperationKey == Operation.deleteKey;
|
||||||
|
|
||||||
|
bool get isNextRetain => nextOperationKey == Operation.retainKey;
|
||||||
|
|
||||||
|
String? get nextOperationKey {
|
||||||
|
if (_index < delta.length) {
|
||||||
|
return delta.elementAt(_index).key;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasNext => peekLength() < maxLength;
|
||||||
|
|
||||||
|
/// Returns length of next operation without consuming it.
|
||||||
|
///
|
||||||
|
/// Returns [maxLength] if there is no more operations left to iterate.
|
||||||
|
int peekLength() {
|
||||||
|
if (_index < delta.length) {
|
||||||
|
final operation = delta._operations[_index];
|
||||||
|
return operation.length! - _offset;
|
||||||
|
}
|
||||||
|
return maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes and returns next operation.
|
||||||
|
///
|
||||||
|
/// Optional [length] specifies maximum length of operation to return. Note
|
||||||
|
/// that actual length of returned operation may be less than specified value.
|
||||||
|
///
|
||||||
|
/// If this iterator reached the end of the Delta then returns a retain
|
||||||
|
/// operation with its length set to [maxLength].
|
||||||
|
// TODO: Note that we used double.infinity as the default value
|
||||||
|
// for length here
|
||||||
|
// but this can now cause a type error since operation length is
|
||||||
|
// expected to be an int. Changing default length to [maxLength] is
|
||||||
|
// a workaround to avoid breaking changes.
|
||||||
|
Operation next([int length = maxLength]) {
|
||||||
|
if (_modificationCount != delta._modificationCount) {
|
||||||
|
throw ConcurrentModificationError(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_index < delta.length) {
|
||||||
|
final op = delta.elementAt(_index);
|
||||||
|
final opKey = op.key;
|
||||||
|
final opAttributes = op.attributes;
|
||||||
|
final _currentOffset = _offset;
|
||||||
|
final actualLength = math.min(op.length! - _currentOffset, length);
|
||||||
|
if (actualLength == op.length! - _currentOffset) {
|
||||||
|
_index++;
|
||||||
|
_offset = 0;
|
||||||
|
} else {
|
||||||
|
_offset += actualLength;
|
||||||
|
}
|
||||||
|
final opData = op.isInsert && op.data is String
|
||||||
|
? (op.data as String)
|
||||||
|
.substring(_currentOffset, _currentOffset + actualLength)
|
||||||
|
: op.data;
|
||||||
|
final opIsNotEmpty =
|
||||||
|
opData is String ? opData.isNotEmpty : true; // embeds are never empty
|
||||||
|
final opLength = opData is String ? opData.length : 1;
|
||||||
|
final opActualLength = opIsNotEmpty ? opLength : actualLength;
|
||||||
|
return Operation._(opKey, opActualLength, opData, opAttributes);
|
||||||
|
}
|
||||||
|
return Operation.retain(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skips [length] characters in source delta.
|
||||||
|
///
|
||||||
|
/// Returns last skipped operation, or `null` if there was nothing to skip.
|
||||||
|
Operation? skip(int length) {
|
||||||
|
var skipped = 0;
|
||||||
|
Operation? op;
|
||||||
|
while (skipped < length && hasNext) {
|
||||||
|
final opLength = peekLength();
|
||||||
|
final skip = math.min(length - skipped, opLength);
|
||||||
|
op = next(skip);
|
||||||
|
skipped += op.length!;
|
||||||
|
}
|
||||||
|
return op;
|
||||||
|
}
|
||||||
|
}
|
126
app_flowy/packages/editor/lib/src/models/rules/delete.dart
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import '../documents/attribute.dart';
|
||||||
|
import '../quill_delta.dart';
|
||||||
|
import 'rule.dart';
|
||||||
|
|
||||||
|
abstract class DeleteRule extends Rule {
|
||||||
|
const DeleteRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
RuleType get type => RuleType.DELETE;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void validateArgs(int? len, Object? data, Attribute? attribute) {
|
||||||
|
assert(len != null);
|
||||||
|
assert(data == null);
|
||||||
|
assert(attribute == null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CatchAllDeleteRule extends DeleteRule {
|
||||||
|
const CatchAllDeleteRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
return Delta()
|
||||||
|
..retain(index)
|
||||||
|
..delete(len!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreserveLineStyleOnMergeRule extends DeleteRule {
|
||||||
|
const PreserveLineStyleOnMergeRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
final itr = DeltaIterator(document)..skip(index);
|
||||||
|
var op = itr.next(1);
|
||||||
|
if (op.data != '\n') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isNotPlain = op.isNotPlain;
|
||||||
|
final attrs = op.attributes;
|
||||||
|
|
||||||
|
itr.skip(len! - 1);
|
||||||
|
final delta = Delta()
|
||||||
|
..retain(index)
|
||||||
|
..delete(len);
|
||||||
|
|
||||||
|
while (itr.hasNext) {
|
||||||
|
op = itr.next();
|
||||||
|
final text = op.data is String ? (op.data as String?)! : '';
|
||||||
|
final lineBreak = text.indexOf('\n');
|
||||||
|
if (lineBreak == -1) {
|
||||||
|
delta.retain(op.length!);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var attributes = op.attributes == null
|
||||||
|
? null
|
||||||
|
: op.attributes!.map<String, dynamic>(
|
||||||
|
(key, dynamic value) => MapEntry<String, dynamic>(key, null));
|
||||||
|
|
||||||
|
if (isNotPlain) {
|
||||||
|
attributes ??= <String, dynamic>{};
|
||||||
|
attributes.addAll(attrs!);
|
||||||
|
}
|
||||||
|
delta
|
||||||
|
..retain(lineBreak)
|
||||||
|
..retain(1, attributes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EnsureEmbedLineRule extends DeleteRule {
|
||||||
|
const EnsureEmbedLineRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
final itr = DeltaIterator(document);
|
||||||
|
|
||||||
|
var op = itr.skip(index);
|
||||||
|
int? indexDelta = 0, lengthDelta = 0, remain = len;
|
||||||
|
var embedFound = op != null && op.data is! String;
|
||||||
|
final hasLineBreakBefore =
|
||||||
|
!embedFound && (op == null || (op.data as String).endsWith('\n'));
|
||||||
|
if (embedFound) {
|
||||||
|
var candidate = itr.next(1);
|
||||||
|
if (remain != null) {
|
||||||
|
remain--;
|
||||||
|
if (candidate.data == '\n') {
|
||||||
|
indexDelta++;
|
||||||
|
lengthDelta--;
|
||||||
|
|
||||||
|
candidate = itr.next(1);
|
||||||
|
remain--;
|
||||||
|
if (candidate.data == '\n') {
|
||||||
|
lengthDelta++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
op = itr.skip(remain!);
|
||||||
|
if (op != null &&
|
||||||
|
(op.data is String ? op.data as String? : '')!.endsWith('\n')) {
|
||||||
|
final candidate = itr.next(1);
|
||||||
|
if (candidate.data is! String && !hasLineBreakBefore) {
|
||||||
|
embedFound = true;
|
||||||
|
lengthDelta--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embedFound) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Delta()
|
||||||
|
..retain(index + indexDelta)
|
||||||
|
..delete(len! + lengthDelta);
|
||||||
|
}
|
||||||
|
}
|
161
app_flowy/packages/editor/lib/src/models/rules/format.dart
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
import '../documents/attribute.dart';
|
||||||
|
import '../quill_delta.dart';
|
||||||
|
import 'rule.dart';
|
||||||
|
|
||||||
|
abstract class FormatRule extends Rule {
|
||||||
|
const FormatRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
RuleType get type => RuleType.FORMAT;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void validateArgs(int? len, Object? data, Attribute? attribute) {
|
||||||
|
assert(len != null);
|
||||||
|
assert(data == null);
|
||||||
|
assert(attribute != null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResolveLineFormatRule extends FormatRule {
|
||||||
|
const ResolveLineFormatRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
if (attribute!.scope != AttributeScope.BLOCK) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var delta = Delta()..retain(index);
|
||||||
|
final itr = DeltaIterator(document)..skip(index);
|
||||||
|
Operation op;
|
||||||
|
for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) {
|
||||||
|
op = itr.next(len - cur);
|
||||||
|
if (op.data is! String || !(op.data as String).contains('\n')) {
|
||||||
|
delta.retain(op.length!);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final text = op.data as String;
|
||||||
|
final tmp = Delta();
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
// Enforce Block Format exclusivity by rule
|
||||||
|
final removedBlocks = Attribute.exclusiveBlockKeys.contains(attribute.key)
|
||||||
|
? op.attributes?.keys
|
||||||
|
.where((key) =>
|
||||||
|
Attribute.exclusiveBlockKeys.contains(key) &&
|
||||||
|
attribute.key != key &&
|
||||||
|
attribute.value != null)
|
||||||
|
.map((key) => MapEntry<String, dynamic>(key, null)) ??
|
||||||
|
[]
|
||||||
|
: <MapEntry<String, dynamic>>[];
|
||||||
|
|
||||||
|
for (var lineBreak = text.indexOf('\n');
|
||||||
|
lineBreak >= 0;
|
||||||
|
lineBreak = text.indexOf('\n', offset)) {
|
||||||
|
tmp
|
||||||
|
..retain(lineBreak - offset)
|
||||||
|
..retain(1, attribute.toJson()..addEntries(removedBlocks));
|
||||||
|
offset = lineBreak + 1;
|
||||||
|
}
|
||||||
|
tmp.retain(text.length - offset);
|
||||||
|
delta = delta.concat(tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (itr.hasNext) {
|
||||||
|
op = itr.next();
|
||||||
|
final text = op.data is String ? (op.data as String?)! : '';
|
||||||
|
final lineBreak = text.indexOf('\n');
|
||||||
|
if (lineBreak < 0) {
|
||||||
|
delta.retain(op.length!);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Enforce Block Format exclusivity by rule
|
||||||
|
final removedBlocks = Attribute.exclusiveBlockKeys.contains(attribute.key)
|
||||||
|
? op.attributes?.keys
|
||||||
|
.where((key) =>
|
||||||
|
Attribute.exclusiveBlockKeys.contains(key) &&
|
||||||
|
attribute.key != key &&
|
||||||
|
attribute.value != null)
|
||||||
|
.map((key) => MapEntry<String, dynamic>(key, null)) ??
|
||||||
|
[]
|
||||||
|
: <MapEntry<String, dynamic>>[];
|
||||||
|
delta
|
||||||
|
..retain(lineBreak)
|
||||||
|
..retain(1, attribute.toJson()..addEntries(removedBlocks));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FormatLinkAtCaretPositionRule extends FormatRule {
|
||||||
|
const FormatLinkAtCaretPositionRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
if (attribute!.key != Attribute.link.key || len! > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final delta = Delta();
|
||||||
|
final itr = DeltaIterator(document);
|
||||||
|
final before = itr.skip(index), after = itr.next();
|
||||||
|
int? beg = index, retain = 0;
|
||||||
|
if (before != null && before.hasAttribute(attribute.key)) {
|
||||||
|
beg -= before.length!;
|
||||||
|
retain = before.length;
|
||||||
|
}
|
||||||
|
if (after.hasAttribute(attribute.key)) {
|
||||||
|
if (retain != null) retain += after.length!;
|
||||||
|
}
|
||||||
|
if (retain == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
delta
|
||||||
|
..retain(beg)
|
||||||
|
..retain(retain!, attribute.toJson());
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResolveInlineFormatRule extends FormatRule {
|
||||||
|
const ResolveInlineFormatRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
if (attribute!.scope != AttributeScope.INLINE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final delta = Delta()..retain(index);
|
||||||
|
final itr = DeltaIterator(document)..skip(index);
|
||||||
|
|
||||||
|
Operation op;
|
||||||
|
for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) {
|
||||||
|
op = itr.next(len - cur);
|
||||||
|
final text = op.data is String ? (op.data as String?)! : '';
|
||||||
|
var lineBreak = text.indexOf('\n');
|
||||||
|
if (lineBreak < 0) {
|
||||||
|
delta.retain(op.length!, attribute.toJson());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var pos = 0;
|
||||||
|
while (lineBreak >= 0) {
|
||||||
|
delta
|
||||||
|
..retain(lineBreak - pos, attribute.toJson())
|
||||||
|
..retain(1);
|
||||||
|
pos = lineBreak + 1;
|
||||||
|
lineBreak = text.indexOf('\n', pos);
|
||||||
|
}
|
||||||
|
if (pos < op.length!) {
|
||||||
|
delta.retain(op.length! - pos, attribute.toJson());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
}
|
385
app_flowy/packages/editor/lib/src/models/rules/insert.dart
Normal file
|
@ -0,0 +1,385 @@
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
import '../documents/attribute.dart';
|
||||||
|
import '../documents/style.dart';
|
||||||
|
import '../quill_delta.dart';
|
||||||
|
import 'rule.dart';
|
||||||
|
|
||||||
|
abstract class InsertRule extends Rule {
|
||||||
|
const InsertRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
RuleType get type => RuleType.INSERT;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void validateArgs(int? len, Object? data, Attribute? attribute) {
|
||||||
|
assert(data != null);
|
||||||
|
assert(attribute == null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreserveLineStyleOnSplitRule extends InsertRule {
|
||||||
|
const PreserveLineStyleOnSplitRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
if (data is! String || data != '\n') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final itr = DeltaIterator(document);
|
||||||
|
final before = itr.skip(index);
|
||||||
|
if (before == null ||
|
||||||
|
before.data is! String ||
|
||||||
|
(before.data as String).endsWith('\n')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final after = itr.next();
|
||||||
|
if (after.data is! String || (after.data as String).startsWith('\n')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final text = after.data as String;
|
||||||
|
|
||||||
|
final delta = Delta()..retain(index + (len ?? 0));
|
||||||
|
if (text.contains('\n')) {
|
||||||
|
assert(after.isPlain);
|
||||||
|
delta.insert('\n');
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
final nextNewLine = _getNextNewLine(itr);
|
||||||
|
final attributes = nextNewLine.item1?.attributes;
|
||||||
|
|
||||||
|
return delta..insert('\n', attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preserves block style when user inserts text containing newlines.
|
||||||
|
///
|
||||||
|
/// This rule handles:
|
||||||
|
///
|
||||||
|
/// * inserting a new line in a block
|
||||||
|
/// * pasting text containing multiple lines of text in a block
|
||||||
|
///
|
||||||
|
/// This rule may also be activated for changes triggered by auto-correct.
|
||||||
|
class PreserveBlockStyleOnInsertRule extends InsertRule {
|
||||||
|
const PreserveBlockStyleOnInsertRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
if (data is! String || !data.contains('\n')) {
|
||||||
|
// Only interested in text containing at least one newline character.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final itr = DeltaIterator(document)..skip(index);
|
||||||
|
|
||||||
|
// Look for the next newline.
|
||||||
|
final nextNewLine = _getNextNewLine(itr);
|
||||||
|
final lineStyle =
|
||||||
|
Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>{});
|
||||||
|
|
||||||
|
final blockStyle = lineStyle.getBlocksExceptHeader();
|
||||||
|
// Are we currently in a block? If not then ignore.
|
||||||
|
if (blockStyle.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? resetStyle;
|
||||||
|
// If current line had heading style applied to it we'll need to move this
|
||||||
|
// style to the newly inserted line before it and reset style of the
|
||||||
|
// original line.
|
||||||
|
if (lineStyle.containsKey(Attribute.header.key)) {
|
||||||
|
resetStyle = Attribute.header.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go over each inserted line and ensure block style is applied.
|
||||||
|
final lines = data.split('\n');
|
||||||
|
final delta = Delta()..retain(index + (len ?? 0));
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
final line = lines[i];
|
||||||
|
if (line.isNotEmpty) {
|
||||||
|
delta.insert(line);
|
||||||
|
}
|
||||||
|
if (i == 0) {
|
||||||
|
// The first line should inherit the lineStyle entirely.
|
||||||
|
delta.insert('\n', lineStyle.toJson());
|
||||||
|
} else if (i < lines.length - 1) {
|
||||||
|
// we don't want to insert a newline after the last chunk of text, so -1
|
||||||
|
delta.insert('\n', blockStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset style of the original newline character if needed.
|
||||||
|
if (resetStyle != null) {
|
||||||
|
delta
|
||||||
|
..retain(nextNewLine.item2!)
|
||||||
|
..retain((nextNewLine.item1!.data as String).indexOf('\n'))
|
||||||
|
..retain(1, resetStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic rule to exit current block when user inserts two consecutive
|
||||||
|
/// newlines.
|
||||||
|
///
|
||||||
|
/// This rule is only applied when the cursor is on the last line of a block.
|
||||||
|
/// When the cursor is in the middle of a block we allow adding empty lines
|
||||||
|
/// and preserving the block's style.
|
||||||
|
class AutoExitBlockRule extends InsertRule {
|
||||||
|
const AutoExitBlockRule();
|
||||||
|
|
||||||
|
bool _isEmptyLine(Operation? before, Operation? after) {
|
||||||
|
if (before == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return before.data is String &&
|
||||||
|
(before.data as String).endsWith('\n') &&
|
||||||
|
after!.data is String &&
|
||||||
|
(after.data as String).startsWith('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
if (data is! String || data != '\n') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final itr = DeltaIterator(document);
|
||||||
|
final prev = itr.skip(index), cur = itr.next();
|
||||||
|
final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader();
|
||||||
|
// We are not in a block, ignore.
|
||||||
|
if (cur.isPlain || blockStyle == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// We are not on an empty line, ignore.
|
||||||
|
if (!_isEmptyLine(prev, cur)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We are on an empty line. Now we need to determine if we are on the
|
||||||
|
// last line of a block.
|
||||||
|
// First check if `cur` length is greater than 1, this would indicate
|
||||||
|
// that it contains multiple newline characters which share the same style.
|
||||||
|
// This would mean we are not on the last line yet.
|
||||||
|
// `cur.value as String` is safe since we already called isEmptyLine and
|
||||||
|
// know it contains a newline
|
||||||
|
if ((cur.value as String).length > 1) {
|
||||||
|
// We are not on the last line of this block, ignore.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep looking for the next newline character to see if it shares the same
|
||||||
|
// block style as `cur`.
|
||||||
|
final nextNewLine = _getNextNewLine(itr);
|
||||||
|
if (nextNewLine.item1 != null &&
|
||||||
|
nextNewLine.item1!.attributes != null &&
|
||||||
|
Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() ==
|
||||||
|
blockStyle) {
|
||||||
|
// We are not at the end of this block, ignore.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we now know that the line after `cur` is not in the same block
|
||||||
|
// therefore we can exit this block.
|
||||||
|
final attributes = cur.attributes ?? <String, dynamic>{};
|
||||||
|
final k =
|
||||||
|
attributes.keys.firstWhere(Attribute.blockKeysExceptHeader.contains);
|
||||||
|
attributes[k] = null;
|
||||||
|
// retain(1) should be '\n', set it with no attribute
|
||||||
|
return Delta()
|
||||||
|
..retain(index + (len ?? 0))
|
||||||
|
..retain(1, attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResetLineFormatOnNewLineRule extends InsertRule {
|
||||||
|
const ResetLineFormatOnNewLineRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
if (data is! String || data != '\n') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final itr = DeltaIterator(document)..skip(index);
|
||||||
|
final cur = itr.next();
|
||||||
|
if (cur.data is! String || !(cur.data as String).startsWith('\n')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? resetStyle;
|
||||||
|
if (cur.attributes != null &&
|
||||||
|
cur.attributes!.containsKey(Attribute.header.key)) {
|
||||||
|
resetStyle = Attribute.header.toJson();
|
||||||
|
}
|
||||||
|
return Delta()
|
||||||
|
..retain(index + (len ?? 0))
|
||||||
|
..insert('\n', cur.attributes)
|
||||||
|
..retain(1, resetStyle)
|
||||||
|
..trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InsertEmbedsRule extends InsertRule {
|
||||||
|
const InsertEmbedsRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
if (data is String) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final delta = Delta()..retain(index + (len ?? 0));
|
||||||
|
final itr = DeltaIterator(document);
|
||||||
|
final prev = itr.skip(index), cur = itr.next();
|
||||||
|
|
||||||
|
final textBefore = prev?.data is String ? prev!.data as String? : '';
|
||||||
|
final textAfter = cur.data is String ? (cur.data as String?)! : '';
|
||||||
|
|
||||||
|
final isNewlineBefore = prev == null || textBefore!.endsWith('\n');
|
||||||
|
final isNewlineAfter = textAfter.startsWith('\n');
|
||||||
|
|
||||||
|
if (isNewlineBefore && isNewlineAfter) {
|
||||||
|
return delta..insert(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? lineStyle;
|
||||||
|
if (textAfter.contains('\n')) {
|
||||||
|
lineStyle = cur.attributes;
|
||||||
|
} else {
|
||||||
|
while (itr.hasNext) {
|
||||||
|
final op = itr.next();
|
||||||
|
if ((op.data is String ? op.data as String? : '')!.contains('\n')) {
|
||||||
|
lineStyle = op.attributes;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNewlineBefore) {
|
||||||
|
delta.insert('\n', lineStyle);
|
||||||
|
}
|
||||||
|
delta.insert(data);
|
||||||
|
if (!isNewlineAfter) {
|
||||||
|
delta.insert('\n');
|
||||||
|
}
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AutoFormatLinksRule extends InsertRule {
|
||||||
|
const AutoFormatLinksRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
if (data is! String || data != ' ') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final itr = DeltaIterator(document);
|
||||||
|
final prev = itr.skip(index);
|
||||||
|
if (prev == null || prev.data is! String) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final cand = (prev.data as String).split('\n').last.split(' ').last;
|
||||||
|
final link = Uri.parse(cand);
|
||||||
|
if (!['https', 'http'].contains(link.scheme)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final attributes = prev.attributes ?? <String, dynamic>{};
|
||||||
|
|
||||||
|
if (attributes.containsKey(Attribute.link.key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
attributes.addAll(LinkAttribute(link.toString()).toJson());
|
||||||
|
return Delta()
|
||||||
|
..retain(index + (len ?? 0) - cand.length)
|
||||||
|
..retain(cand.length, attributes)
|
||||||
|
..insert(data, prev.attributes);
|
||||||
|
} on FormatException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreserveInlineStylesRule extends InsertRule {
|
||||||
|
const PreserveInlineStylesRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
if (data is! String || data.contains('\n')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final itr = DeltaIterator(document);
|
||||||
|
final prev = itr.skip(index);
|
||||||
|
if (prev == null ||
|
||||||
|
prev.data is! String ||
|
||||||
|
(prev.data as String).contains('\n')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final attributes = prev.attributes;
|
||||||
|
final text = data;
|
||||||
|
if (attributes == null || !attributes.containsKey(Attribute.link.key)) {
|
||||||
|
return Delta()
|
||||||
|
..retain(index + (len ?? 0))
|
||||||
|
..insert(text, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
attributes.remove(Attribute.link.key);
|
||||||
|
final delta = Delta()
|
||||||
|
..retain(index + (len ?? 0))
|
||||||
|
..insert(text, attributes.isEmpty ? null : attributes);
|
||||||
|
final next = itr.next();
|
||||||
|
|
||||||
|
final nextAttributes = next.attributes ?? const <String, dynamic>{};
|
||||||
|
if (!nextAttributes.containsKey(Attribute.link.key)) {
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
if (attributes[Attribute.link.key] == nextAttributes[Attribute.link.key]) {
|
||||||
|
return Delta()
|
||||||
|
..retain(index + (len ?? 0))
|
||||||
|
..insert(text, attributes);
|
||||||
|
}
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CatchAllInsertRule extends InsertRule {
|
||||||
|
const CatchAllInsertRule();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Delta applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
return Delta()
|
||||||
|
..retain(index + (len ?? 0))
|
||||||
|
..insert(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2<Operation?, int?> _getNextNewLine(DeltaIterator iterator) {
|
||||||
|
Operation op;
|
||||||
|
for (var skipped = 0; iterator.hasNext; skipped += op.length!) {
|
||||||
|
op = iterator.next();
|
||||||
|
final lineBreak =
|
||||||
|
(op.data is String ? op.data as String? : '')!.indexOf('\n');
|
||||||
|
if (lineBreak >= 0) {
|
||||||
|
return Tuple2(op, skipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const Tuple2(null, null);
|
||||||
|
}
|
76
app_flowy/packages/editor/lib/src/models/rules/rule.dart
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import '../documents/attribute.dart';
|
||||||
|
import '../documents/document.dart';
|
||||||
|
import '../quill_delta.dart';
|
||||||
|
import 'delete.dart';
|
||||||
|
import 'format.dart';
|
||||||
|
import 'insert.dart';
|
||||||
|
|
||||||
|
enum RuleType { INSERT, DELETE, FORMAT }
|
||||||
|
|
||||||
|
abstract class Rule {
|
||||||
|
const Rule();
|
||||||
|
|
||||||
|
Delta? apply(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
validateArgs(len, data, attribute);
|
||||||
|
return applyRule(document, index,
|
||||||
|
len: len, data: data, attribute: attribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
void validateArgs(int? len, Object? data, Attribute? attribute);
|
||||||
|
|
||||||
|
Delta? applyRule(Delta document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute});
|
||||||
|
|
||||||
|
RuleType get type;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Rules {
|
||||||
|
Rules(this._rules);
|
||||||
|
|
||||||
|
List<Rule> _customRules = [];
|
||||||
|
|
||||||
|
final List<Rule> _rules;
|
||||||
|
static final Rules _instance = Rules([
|
||||||
|
const FormatLinkAtCaretPositionRule(),
|
||||||
|
const ResolveLineFormatRule(),
|
||||||
|
const ResolveInlineFormatRule(),
|
||||||
|
const InsertEmbedsRule(),
|
||||||
|
const AutoExitBlockRule(),
|
||||||
|
const PreserveBlockStyleOnInsertRule(),
|
||||||
|
const PreserveLineStyleOnSplitRule(),
|
||||||
|
const ResetLineFormatOnNewLineRule(),
|
||||||
|
const AutoFormatLinksRule(),
|
||||||
|
const PreserveInlineStylesRule(),
|
||||||
|
const CatchAllInsertRule(),
|
||||||
|
const EnsureEmbedLineRule(),
|
||||||
|
const PreserveLineStyleOnMergeRule(),
|
||||||
|
const CatchAllDeleteRule(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
static Rules getInstance() => _instance;
|
||||||
|
|
||||||
|
void setCustomRules(List<Rule> customRules) {
|
||||||
|
_customRules = customRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
Delta apply(RuleType ruleType, Document document, int index,
|
||||||
|
{int? len, Object? data, Attribute? attribute}) {
|
||||||
|
final delta = document.toDelta();
|
||||||
|
for (final rule in _customRules + _rules) {
|
||||||
|
if (rule.type != ruleType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final result = rule.apply(delta, index,
|
||||||
|
len: len, data: data, attribute: attribute);
|
||||||
|
if (result != null) {
|
||||||
|
return result..trim();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw 'Apply rules failed';
|
||||||
|
}
|
||||||
|
}
|
125
app_flowy/packages/editor/lib/src/utils/color.dart
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
Color stringToColor(String? s) {
|
||||||
|
switch (s) {
|
||||||
|
case 'transparent':
|
||||||
|
return Colors.transparent;
|
||||||
|
case 'black':
|
||||||
|
return Colors.black;
|
||||||
|
case 'black12':
|
||||||
|
return Colors.black12;
|
||||||
|
case 'black26':
|
||||||
|
return Colors.black26;
|
||||||
|
case 'black38':
|
||||||
|
return Colors.black38;
|
||||||
|
case 'black45':
|
||||||
|
return Colors.black45;
|
||||||
|
case 'black54':
|
||||||
|
return Colors.black54;
|
||||||
|
case 'black87':
|
||||||
|
return Colors.black87;
|
||||||
|
case 'white':
|
||||||
|
return Colors.white;
|
||||||
|
case 'white10':
|
||||||
|
return Colors.white10;
|
||||||
|
case 'white12':
|
||||||
|
return Colors.white12;
|
||||||
|
case 'white24':
|
||||||
|
return Colors.white24;
|
||||||
|
case 'white30':
|
||||||
|
return Colors.white30;
|
||||||
|
case 'white38':
|
||||||
|
return Colors.white38;
|
||||||
|
case 'white54':
|
||||||
|
return Colors.white54;
|
||||||
|
case 'white60':
|
||||||
|
return Colors.white60;
|
||||||
|
case 'white70':
|
||||||
|
return Colors.white70;
|
||||||
|
case 'red':
|
||||||
|
return Colors.red;
|
||||||
|
case 'redAccent':
|
||||||
|
return Colors.redAccent;
|
||||||
|
case 'amber':
|
||||||
|
return Colors.amber;
|
||||||
|
case 'amberAccent':
|
||||||
|
return Colors.amberAccent;
|
||||||
|
case 'yellow':
|
||||||
|
return Colors.yellow;
|
||||||
|
case 'yellowAccent':
|
||||||
|
return Colors.yellowAccent;
|
||||||
|
case 'teal':
|
||||||
|
return Colors.teal;
|
||||||
|
case 'tealAccent':
|
||||||
|
return Colors.tealAccent;
|
||||||
|
case 'purple':
|
||||||
|
return Colors.purple;
|
||||||
|
case 'purpleAccent':
|
||||||
|
return Colors.purpleAccent;
|
||||||
|
case 'pink':
|
||||||
|
return Colors.pink;
|
||||||
|
case 'pinkAccent':
|
||||||
|
return Colors.pinkAccent;
|
||||||
|
case 'orange':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'orangeAccent':
|
||||||
|
return Colors.orangeAccent;
|
||||||
|
case 'deepOrange':
|
||||||
|
return Colors.deepOrange;
|
||||||
|
case 'deepOrangeAccent':
|
||||||
|
return Colors.deepOrangeAccent;
|
||||||
|
case 'indigo':
|
||||||
|
return Colors.indigo;
|
||||||
|
case 'indigoAccent':
|
||||||
|
return Colors.indigoAccent;
|
||||||
|
case 'lime':
|
||||||
|
return Colors.lime;
|
||||||
|
case 'limeAccent':
|
||||||
|
return Colors.limeAccent;
|
||||||
|
case 'grey':
|
||||||
|
return Colors.grey;
|
||||||
|
case 'blueGrey':
|
||||||
|
return Colors.blueGrey;
|
||||||
|
case 'green':
|
||||||
|
return Colors.green;
|
||||||
|
case 'greenAccent':
|
||||||
|
return Colors.greenAccent;
|
||||||
|
case 'lightGreen':
|
||||||
|
return Colors.lightGreen;
|
||||||
|
case 'lightGreenAccent':
|
||||||
|
return Colors.lightGreenAccent;
|
||||||
|
case 'blue':
|
||||||
|
return Colors.blue;
|
||||||
|
case 'blueAccent':
|
||||||
|
return Colors.blueAccent;
|
||||||
|
case 'lightBlue':
|
||||||
|
return Colors.lightBlue;
|
||||||
|
case 'lightBlueAccent':
|
||||||
|
return Colors.lightBlueAccent;
|
||||||
|
case 'cyan':
|
||||||
|
return Colors.cyan;
|
||||||
|
case 'cyanAccent':
|
||||||
|
return Colors.cyanAccent;
|
||||||
|
case 'brown':
|
||||||
|
return Colors.brown;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s!.startsWith('rgba')) {
|
||||||
|
s = s.substring(5); // trim left 'rgba('
|
||||||
|
s = s.substring(0, s.length - 1); // trim right ')'
|
||||||
|
final arr = s.split(',').map((e) => e.trim()).toList();
|
||||||
|
return Color.fromRGBO(int.parse(arr[0]), int.parse(arr[1]),
|
||||||
|
int.parse(arr[2]), double.parse(arr[3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!s.startsWith('#')) {
|
||||||
|
throw 'Color code not supported';
|
||||||
|
}
|
||||||
|
|
||||||
|
var hex = s.replaceFirst('#', '');
|
||||||
|
hex = hex.length == 6 ? 'ff$hex' : hex;
|
||||||
|
final val = int.parse(hex, radix: 16);
|
||||||
|
return Color(val);
|
||||||
|
}
|
103
app_flowy/packages/editor/lib/src/utils/diff_delta.dart
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import '../models/quill_delta.dart';
|
||||||
|
|
||||||
|
const Set<int> WHITE_SPACE = {
|
||||||
|
0x9,
|
||||||
|
0xA,
|
||||||
|
0xB,
|
||||||
|
0xC,
|
||||||
|
0xD,
|
||||||
|
0x1C,
|
||||||
|
0x1D,
|
||||||
|
0x1E,
|
||||||
|
0x1F,
|
||||||
|
0x20,
|
||||||
|
0xA0,
|
||||||
|
0x1680,
|
||||||
|
0x2000,
|
||||||
|
0x2001,
|
||||||
|
0x2002,
|
||||||
|
0x2003,
|
||||||
|
0x2004,
|
||||||
|
0x2005,
|
||||||
|
0x2006,
|
||||||
|
0x2007,
|
||||||
|
0x2008,
|
||||||
|
0x2009,
|
||||||
|
0x200A,
|
||||||
|
0x202F,
|
||||||
|
0x205F,
|
||||||
|
0x3000
|
||||||
|
};
|
||||||
|
|
||||||
|
// Diff between two texts - old text and new text
|
||||||
|
class Diff {
|
||||||
|
Diff(this.start, this.deleted, this.inserted);
|
||||||
|
|
||||||
|
// Start index in old text at which changes begin.
|
||||||
|
final int start;
|
||||||
|
|
||||||
|
/// The deleted text
|
||||||
|
final String deleted;
|
||||||
|
|
||||||
|
// The inserted text
|
||||||
|
final String inserted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Diff[$start, "$deleted", "$inserted"]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Get diff operation between old text and new text */
|
||||||
|
Diff getDiff(String oldText, String newText, int cursorPosition) {
|
||||||
|
var end = oldText.length;
|
||||||
|
final delta = newText.length - end;
|
||||||
|
for (final limit = math.max(0, cursorPosition - delta);
|
||||||
|
end > limit && oldText[end - 1] == newText[end + delta - 1];
|
||||||
|
end--) {}
|
||||||
|
var start = 0;
|
||||||
|
for (final startLimit = cursorPosition - math.max(0, delta);
|
||||||
|
start < startLimit && oldText[start] == newText[start];
|
||||||
|
start++) {}
|
||||||
|
final deleted = (start >= end) ? '' : oldText.substring(start, end);
|
||||||
|
final inserted = newText.substring(start, end + delta);
|
||||||
|
return Diff(start, deleted, inserted);
|
||||||
|
}
|
||||||
|
|
||||||
|
int getPositionDelta(Delta user, Delta actual) {
|
||||||
|
if (actual.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final userItr = DeltaIterator(user);
|
||||||
|
final actualItr = DeltaIterator(actual);
|
||||||
|
var diff = 0;
|
||||||
|
while (userItr.hasNext || actualItr.hasNext) {
|
||||||
|
final length = math.min(userItr.peekLength(), actualItr.peekLength());
|
||||||
|
final userOperation = userItr.next(length);
|
||||||
|
final actualOperation = actualItr.next(length);
|
||||||
|
if (userOperation.length != actualOperation.length) {
|
||||||
|
throw 'userOp ${userOperation.length} does not match actualOp '
|
||||||
|
'${actualOperation.length}';
|
||||||
|
}
|
||||||
|
if (userOperation.key == actualOperation.key) {
|
||||||
|
continue;
|
||||||
|
} else if (userOperation.isInsert && actualOperation.isRetain) {
|
||||||
|
diff -= userOperation.length!;
|
||||||
|
} else if (userOperation.isDelete && actualOperation.isRetain) {
|
||||||
|
diff += userOperation.length!;
|
||||||
|
} else if (userOperation.isRetain && actualOperation.isInsert) {
|
||||||
|
String? operationTxt = '';
|
||||||
|
if (actualOperation.data is String) {
|
||||||
|
operationTxt = actualOperation.data as String?;
|
||||||
|
}
|
||||||
|
if (operationTxt!.startsWith('\n')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
diff += actualOperation.length!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diff;
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
enum MediaPickSetting {
|
||||||
|
Gallery,
|
||||||
|
Link,
|
||||||
|
}
|
16
app_flowy/packages/editor/lib/src/utils/string_helper.dart
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
Map<String, String> parseKeyValuePairs(String s, Set<String> targetKeys) {
|
||||||
|
final result = <String, String>{};
|
||||||
|
final pairs = s.split(';');
|
||||||
|
for (final pair in pairs) {
|
||||||
|
final _index = pair.indexOf(':');
|
||||||
|
if (_index < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final _key = pair.substring(0, _index).trim();
|
||||||
|
if (targetKeys.contains(_key)) {
|
||||||
|
result[_key] = pair.substring(_index + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
122
app_flowy/packages/editor/lib/src/widgets/box.dart
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
|
import '../models/documents/nodes/container.dart';
|
||||||
|
|
||||||
|
abstract class RenderContentProxyBox implements RenderBox {
|
||||||
|
double getPreferredLineHeight();
|
||||||
|
|
||||||
|
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype);
|
||||||
|
|
||||||
|
TextPosition getPositionForOffset(Offset offset);
|
||||||
|
|
||||||
|
double? getFullHeightForCaret(TextPosition position);
|
||||||
|
|
||||||
|
TextRange getWordBoundary(TextPosition position);
|
||||||
|
|
||||||
|
List<TextBox> getBoxesForSelection(TextSelection textSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base class for render boxes of editable content.
|
||||||
|
///
|
||||||
|
/// Implementations of this class usually work as a wrapper around
|
||||||
|
/// regular (non-editable) render boxes which implement
|
||||||
|
/// [RenderContentProxyBox].
|
||||||
|
abstract class RenderEditableBox extends RenderBox {
|
||||||
|
/// The document node represented by this render box.
|
||||||
|
Container getContainer();
|
||||||
|
|
||||||
|
/// Returns preferred line height at specified `position` in text.
|
||||||
|
///
|
||||||
|
/// The `position` parameter must be relative to the [node]'s content.
|
||||||
|
double preferredLineHeight(TextPosition position);
|
||||||
|
|
||||||
|
/// Returns the offset at which to paint the caret.
|
||||||
|
///
|
||||||
|
/// The `position` parameter must be relative to the [node]'s content.
|
||||||
|
///
|
||||||
|
/// Valid only after [layout].
|
||||||
|
Offset getOffsetForCaret(TextPosition position);
|
||||||
|
|
||||||
|
/// Returns the position within the text for the given pixel offset.
|
||||||
|
///
|
||||||
|
/// The `offset` parameter must be local to this box coordinate system.
|
||||||
|
///
|
||||||
|
/// Valid only after [layout].
|
||||||
|
TextPosition getPositionForOffset(Offset offset);
|
||||||
|
|
||||||
|
/// Returns the position relative to the [node] content
|
||||||
|
///
|
||||||
|
/// The `position` must be within the [node] content
|
||||||
|
TextPosition globalToLocalPosition(TextPosition position);
|
||||||
|
|
||||||
|
/// Returns the position within the text which is on the line above the given
|
||||||
|
/// `position`.
|
||||||
|
///
|
||||||
|
/// The `position` parameter must be relative to the [node] content.
|
||||||
|
///
|
||||||
|
/// Primarily used with multi-line or soft-wrapping text.
|
||||||
|
///
|
||||||
|
/// Can return `null` which indicates that the `position` is at the topmost
|
||||||
|
/// line in the text already.
|
||||||
|
TextPosition? getPositionAbove(TextPosition position);
|
||||||
|
|
||||||
|
/// Returns the position within the text which is on the line below the given
|
||||||
|
/// `position`.
|
||||||
|
///
|
||||||
|
/// The `position` parameter must be relative to the [node] content.
|
||||||
|
///
|
||||||
|
/// Primarily used with multi-line or soft-wrapping text.
|
||||||
|
///
|
||||||
|
/// Can return `null` which indicates that the `position` is at the bottommost
|
||||||
|
/// line in the text already.
|
||||||
|
TextPosition? getPositionBelow(TextPosition position);
|
||||||
|
|
||||||
|
/// Returns the text range of the word at the given offset. Characters not
|
||||||
|
/// part of a word, such as spaces, symbols, and punctuation, have word breaks
|
||||||
|
/// on both sides. In such cases, this method will return a text range that
|
||||||
|
/// contains the given text position.
|
||||||
|
///
|
||||||
|
/// Word boundaries are defined more precisely in Unicode Standard Annex #29
|
||||||
|
/// <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
|
||||||
|
///
|
||||||
|
/// The `position` parameter must be relative to the [node]'s content.
|
||||||
|
///
|
||||||
|
/// Valid only after [layout].
|
||||||
|
TextRange getWordBoundary(TextPosition position);
|
||||||
|
|
||||||
|
/// Returns the text range of the line at the given offset.
|
||||||
|
///
|
||||||
|
/// The newline, if any, is included in the range.
|
||||||
|
///
|
||||||
|
/// The `position` parameter must be relative to the [node]'s content.
|
||||||
|
///
|
||||||
|
/// Valid only after [layout].
|
||||||
|
TextRange getLineBoundary(TextPosition position);
|
||||||
|
|
||||||
|
/// Returns a list of rects that bound the given selection.
|
||||||
|
///
|
||||||
|
/// A given selection might have more than one rect if this text painter
|
||||||
|
/// contains bidirectional text because logically contiguous text might not be
|
||||||
|
/// visually contiguous.
|
||||||
|
///
|
||||||
|
/// Valid only after [layout].
|
||||||
|
// List<TextBox> getBoxesForSelection(TextSelection selection);
|
||||||
|
|
||||||
|
/// Returns a point for the base selection handle used on touch-oriented
|
||||||
|
/// devices.
|
||||||
|
///
|
||||||
|
/// The `selection` parameter is expected to be in local offsets to this
|
||||||
|
/// render object's [node].
|
||||||
|
TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection);
|
||||||
|
|
||||||
|
/// Returns a point for the extent selection handle used on touch-oriented
|
||||||
|
/// devices.
|
||||||
|
///
|
||||||
|
/// The `selection` parameter is expected to be in local offsets to this
|
||||||
|
/// render object's [node].
|
||||||
|
TextSelectionPoint getExtentEndpointForSelection(TextSelection textSelection);
|
||||||
|
|
||||||
|
/// Returns the [Rect] in local coordinates for the caret at the given text
|
||||||
|
/// position.
|
||||||
|
Rect getLocalRectForCaret(TextPosition position);
|
||||||
|
}
|
255
app_flowy/packages/editor/lib/src/widgets/controller.dart
Normal file
|
@ -0,0 +1,255 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
import '../models/documents/attribute.dart';
|
||||||
|
import '../models/documents/document.dart';
|
||||||
|
import '../models/documents/nodes/embed.dart';
|
||||||
|
import '../models/documents/style.dart';
|
||||||
|
import '../models/quill_delta.dart';
|
||||||
|
import '../utils/diff_delta.dart';
|
||||||
|
|
||||||
|
class QuillController extends ChangeNotifier {
|
||||||
|
QuillController({
|
||||||
|
required this.document,
|
||||||
|
required TextSelection selection,
|
||||||
|
bool keepStyleOnNewLine = false,
|
||||||
|
}) : _selection = selection,
|
||||||
|
_keepStyleOnNewLine = keepStyleOnNewLine;
|
||||||
|
|
||||||
|
factory QuillController.basic() {
|
||||||
|
return QuillController(
|
||||||
|
document: Document(),
|
||||||
|
selection: const TextSelection.collapsed(offset: 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Document managed by this controller.
|
||||||
|
final Document document;
|
||||||
|
|
||||||
|
/// Tells whether to keep or reset the [toggledStyle]
|
||||||
|
/// when user adds a new line.
|
||||||
|
final bool _keepStyleOnNewLine;
|
||||||
|
|
||||||
|
/// Currently selected text within the [document].
|
||||||
|
TextSelection get selection => _selection;
|
||||||
|
TextSelection _selection;
|
||||||
|
|
||||||
|
/// Store any styles attribute that got toggled by the tap of a button
|
||||||
|
/// and that has not been applied yet.
|
||||||
|
/// It gets reset after each format action within the [document].
|
||||||
|
Style toggledStyle = Style();
|
||||||
|
|
||||||
|
bool ignoreFocusOnTextChange = false;
|
||||||
|
|
||||||
|
/// True when this [QuillController] instance has been disposed.
|
||||||
|
///
|
||||||
|
/// A safety mechanism to ensure that listeners don't crash when adding,
|
||||||
|
/// removing or listeners to this instance.
|
||||||
|
bool _isDisposed = false;
|
||||||
|
|
||||||
|
// item1: Document state before [change].
|
||||||
|
//
|
||||||
|
// item2: Change delta applied to the document.
|
||||||
|
//
|
||||||
|
// item3: The source of this change.
|
||||||
|
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => document.changes;
|
||||||
|
|
||||||
|
TextEditingValue get plainTextEditingValue => TextEditingValue(
|
||||||
|
text: document.toPlainText(),
|
||||||
|
selection: selection,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Only attributes applied to all characters within this range are
|
||||||
|
/// included in the result.
|
||||||
|
Style getSelectionStyle() {
|
||||||
|
return document
|
||||||
|
.collectStyle(selection.start, selection.end - selection.start)
|
||||||
|
.mergeAll(toggledStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all styles for any character within the specified text range.
|
||||||
|
List<Style> getAllSelectionStyles() {
|
||||||
|
final styles = document.collectAllStyles(
|
||||||
|
selection.start, selection.end - selection.start)
|
||||||
|
..add(toggledStyle);
|
||||||
|
return styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
void undo() {
|
||||||
|
final tup = document.undo();
|
||||||
|
if (tup.item1) {
|
||||||
|
_handleHistoryChange(tup.item2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleHistoryChange(int? len) {
|
||||||
|
if (len! != 0) {
|
||||||
|
// if (this.selection.extentOffset >= document.length) {
|
||||||
|
// // cursor exceeds the length of document, position it in the end
|
||||||
|
// updateSelection(
|
||||||
|
// TextSelection.collapsed(offset: document.length), ChangeSource.LOCAL);
|
||||||
|
updateSelection(
|
||||||
|
TextSelection.collapsed(offset: selection.baseOffset + len),
|
||||||
|
ChangeSource.LOCAL);
|
||||||
|
} else {
|
||||||
|
// no need to move cursor
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void redo() {
|
||||||
|
final tup = document.redo();
|
||||||
|
if (tup.item1) {
|
||||||
|
_handleHistoryChange(tup.item2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasUndo => document.hasUndo;
|
||||||
|
|
||||||
|
bool get hasRedo => document.hasRedo;
|
||||||
|
|
||||||
|
void replaceText(
|
||||||
|
int index, int len, Object? data, TextSelection? textSelection,
|
||||||
|
{bool ignoreFocus = false}) {
|
||||||
|
assert(data is String || data is Embeddable);
|
||||||
|
|
||||||
|
Delta? delta;
|
||||||
|
if (len > 0 || data is! String || data.isNotEmpty) {
|
||||||
|
delta = document.replace(index, len, data);
|
||||||
|
var shouldRetainDelta = toggledStyle.isNotEmpty &&
|
||||||
|
delta.isNotEmpty &&
|
||||||
|
delta.length <= 2 &&
|
||||||
|
delta.last.isInsert;
|
||||||
|
if (shouldRetainDelta &&
|
||||||
|
toggledStyle.isNotEmpty &&
|
||||||
|
delta.length == 2 &&
|
||||||
|
delta.last.data == '\n') {
|
||||||
|
// if all attributes are inline, shouldRetainDelta should be false
|
||||||
|
final anyAttributeNotInline =
|
||||||
|
toggledStyle.values.any((attr) => !attr.isInline);
|
||||||
|
if (!anyAttributeNotInline) {
|
||||||
|
shouldRetainDelta = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldRetainDelta) {
|
||||||
|
final retainDelta = Delta()
|
||||||
|
..retain(index)
|
||||||
|
..retain(data is String ? data.length : 1, toggledStyle.toJson());
|
||||||
|
document.compose(retainDelta, ChangeSource.LOCAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_keepStyleOnNewLine) {
|
||||||
|
final style = getSelectionStyle();
|
||||||
|
final notInlineStyle = style.attributes.values.where((s) => !s.isInline);
|
||||||
|
toggledStyle = style.removeAll(notInlineStyle.toSet());
|
||||||
|
} else {
|
||||||
|
toggledStyle = Style();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textSelection != null) {
|
||||||
|
if (delta == null || delta.isEmpty) {
|
||||||
|
_updateSelection(textSelection, ChangeSource.LOCAL);
|
||||||
|
} else {
|
||||||
|
final user = Delta()
|
||||||
|
..retain(index)
|
||||||
|
..insert(data)
|
||||||
|
..delete(len);
|
||||||
|
final positionDelta = getPositionDelta(user, delta);
|
||||||
|
_updateSelection(
|
||||||
|
textSelection.copyWith(
|
||||||
|
baseOffset: textSelection.baseOffset + positionDelta,
|
||||||
|
extentOffset: textSelection.extentOffset + positionDelta,
|
||||||
|
),
|
||||||
|
ChangeSource.LOCAL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ignoreFocus) {
|
||||||
|
ignoreFocusOnTextChange = true;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
ignoreFocusOnTextChange = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void formatText(int index, int len, Attribute? attribute) {
|
||||||
|
if (len == 0 &&
|
||||||
|
attribute!.isInline &&
|
||||||
|
attribute.key != Attribute.link.key) {
|
||||||
|
toggledStyle = toggledStyle.put(attribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
final change = document.format(index, len, attribute);
|
||||||
|
final adjustedSelection = selection.copyWith(
|
||||||
|
baseOffset: change.transformPosition(selection.baseOffset),
|
||||||
|
extentOffset: change.transformPosition(selection.extentOffset));
|
||||||
|
if (selection != adjustedSelection) {
|
||||||
|
_updateSelection(adjustedSelection, ChangeSource.LOCAL);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void formatSelection(Attribute? attribute) {
|
||||||
|
formatText(selection.start, selection.end - selection.start, attribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateSelection(TextSelection textSelection, ChangeSource source) {
|
||||||
|
_updateSelection(textSelection, source);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void compose(Delta delta, TextSelection textSelection, ChangeSource source) {
|
||||||
|
if (delta.isNotEmpty) {
|
||||||
|
document.compose(delta, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
textSelection = selection.copyWith(
|
||||||
|
baseOffset: delta.transformPosition(selection.baseOffset, force: false),
|
||||||
|
extentOffset:
|
||||||
|
delta.transformPosition(selection.extentOffset, force: false));
|
||||||
|
if (selection != textSelection) {
|
||||||
|
_updateSelection(textSelection, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void addListener(VoidCallback listener) {
|
||||||
|
// By using `_isDisposed`, make sure that `addListener` won't be called on a
|
||||||
|
// disposed `ChangeListener`
|
||||||
|
if (!_isDisposed) {
|
||||||
|
super.addListener(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void removeListener(VoidCallback listener) {
|
||||||
|
// By using `_isDisposed`, make sure that `removeListener` won't be called
|
||||||
|
// on a disposed `ChangeListener`
|
||||||
|
if (!_isDisposed) {
|
||||||
|
super.removeListener(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (!_isDisposed) {
|
||||||
|
document.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDisposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSelection(TextSelection textSelection, ChangeSource source) {
|
||||||
|
_selection = textSelection;
|
||||||
|
final end = document.length - 1;
|
||||||
|
_selection = selection.copyWith(
|
||||||
|
baseOffset: math.min(selection.baseOffset, end),
|
||||||
|
extentOffset: math.min(selection.extentOffset, end));
|
||||||
|
}
|
||||||
|
}
|
341
app_flowy/packages/editor/lib/src/widgets/cursor.dart
Normal file
|
@ -0,0 +1,341 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'box.dart';
|
||||||
|
|
||||||
|
/// Style properties of editing cursor.
|
||||||
|
class CursorStyle {
|
||||||
|
const CursorStyle({
|
||||||
|
required this.color,
|
||||||
|
required this.backgroundColor,
|
||||||
|
this.width = 1.0,
|
||||||
|
this.height,
|
||||||
|
this.radius,
|
||||||
|
this.offset,
|
||||||
|
this.opacityAnimates = false,
|
||||||
|
this.paintAboveText = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The color to use when painting the cursor.
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// The color to use when painting the background cursor aligned with the text
|
||||||
|
/// while rendering the floating cursor.
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
/// How thick the cursor will be.
|
||||||
|
///
|
||||||
|
/// The cursor will draw under the text. The cursor width will extend
|
||||||
|
/// to the right of the boundary between characters for left-to-right text
|
||||||
|
/// and to the left for right-to-left text. This corresponds to extending
|
||||||
|
/// downstream relative to the selected position. Negative values may be used
|
||||||
|
/// to reverse this behavior.
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
/// How tall the cursor will be.
|
||||||
|
///
|
||||||
|
/// By default, the cursor height is set to the preferred line height of the
|
||||||
|
/// text.
|
||||||
|
final double? height;
|
||||||
|
|
||||||
|
/// How rounded the corners of the cursor should be.
|
||||||
|
///
|
||||||
|
/// By default, the cursor has no radius.
|
||||||
|
final Radius? radius;
|
||||||
|
|
||||||
|
/// The offset that is used, in pixels, when painting the cursor on screen.
|
||||||
|
///
|
||||||
|
/// By default, the cursor position should be set to an offset of
|
||||||
|
/// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android
|
||||||
|
/// platforms. The origin from where the offset is applied to is the arbitrary
|
||||||
|
/// location where the cursor ends up being rendered from by default.
|
||||||
|
final Offset? offset;
|
||||||
|
|
||||||
|
/// Whether the cursor will animate from fully transparent to fully opaque
|
||||||
|
/// during each cursor blink.
|
||||||
|
///
|
||||||
|
/// By default, the cursor opacity will animate on iOS platforms and will not
|
||||||
|
/// animate on Android platforms.
|
||||||
|
final bool opacityAnimates;
|
||||||
|
|
||||||
|
/// If the cursor should be painted on top of the text or underneath it.
|
||||||
|
///
|
||||||
|
/// By default, the cursor should be painted on top for iOS platforms and
|
||||||
|
/// underneath for Android platforms.
|
||||||
|
final bool paintAboveText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is CursorStyle &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
color == other.color &&
|
||||||
|
backgroundColor == other.backgroundColor &&
|
||||||
|
width == other.width &&
|
||||||
|
height == other.height &&
|
||||||
|
radius == other.radius &&
|
||||||
|
offset == other.offset &&
|
||||||
|
opacityAnimates == other.opacityAnimates &&
|
||||||
|
paintAboveText == other.paintAboveText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
color.hashCode ^
|
||||||
|
backgroundColor.hashCode ^
|
||||||
|
width.hashCode ^
|
||||||
|
height.hashCode ^
|
||||||
|
radius.hashCode ^
|
||||||
|
offset.hashCode ^
|
||||||
|
opacityAnimates.hashCode ^
|
||||||
|
paintAboveText.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controls the cursor of an editable widget.
|
||||||
|
///
|
||||||
|
/// This class is a [ChangeNotifier] and allows to listen for updates on the
|
||||||
|
/// cursor [style].
|
||||||
|
class CursorCont extends ChangeNotifier {
|
||||||
|
CursorCont({
|
||||||
|
required this.show,
|
||||||
|
required CursorStyle style,
|
||||||
|
required TickerProvider tickerProvider,
|
||||||
|
}) : _style = style,
|
||||||
|
blink = ValueNotifier(false),
|
||||||
|
color = ValueNotifier(style.color) {
|
||||||
|
_blinkOpacityController =
|
||||||
|
AnimationController(vsync: tickerProvider, duration: _fadeDuration);
|
||||||
|
_blinkOpacityController.addListener(_onColorTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The time it takes for the cursor to fade from fully opaque to fully
|
||||||
|
// transparent and vice versa. A full cursor blink, from transparent to opaque
|
||||||
|
// to transparent, is twice this duration.
|
||||||
|
static const Duration _blinkHalfPeriod = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
// The time the cursor is static in opacity before animating to become
|
||||||
|
// transparent.
|
||||||
|
static const Duration _blinkWaitForStart = Duration(milliseconds: 150);
|
||||||
|
|
||||||
|
// This value is an eyeball estimation of the time it takes for the iOS cursor
|
||||||
|
// to ease in and out.
|
||||||
|
static const Duration _fadeDuration = Duration(milliseconds: 250);
|
||||||
|
|
||||||
|
final ValueNotifier<bool> show;
|
||||||
|
final ValueNotifier<Color> color;
|
||||||
|
final ValueNotifier<bool> blink;
|
||||||
|
|
||||||
|
late final AnimationController _blinkOpacityController;
|
||||||
|
|
||||||
|
Timer? _cursorTimer;
|
||||||
|
bool _targetCursorVisibility = false;
|
||||||
|
|
||||||
|
CursorStyle _style;
|
||||||
|
CursorStyle get style => _style;
|
||||||
|
set style(CursorStyle value) {
|
||||||
|
if (_style == value) return;
|
||||||
|
_style = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this [CursorCont] instance has been disposed.
|
||||||
|
///
|
||||||
|
/// A safety mechanism to prevent the value of a disposed controller from
|
||||||
|
/// getting set.
|
||||||
|
bool _isDisposed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_blinkOpacityController.removeListener(_onColorTick);
|
||||||
|
stopCursorTimer();
|
||||||
|
|
||||||
|
_isDisposed = true;
|
||||||
|
_blinkOpacityController.dispose();
|
||||||
|
show.dispose();
|
||||||
|
blink.dispose();
|
||||||
|
color.dispose();
|
||||||
|
assert(_cursorTimer == null);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cursorTick(Timer timer) {
|
||||||
|
_targetCursorVisibility = !_targetCursorVisibility;
|
||||||
|
final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0;
|
||||||
|
if (style.opacityAnimates) {
|
||||||
|
// If we want to show the cursor, we will animate the opacity to the value
|
||||||
|
// of 1.0, and likewise if we want to make it disappear, to 0.0. An easing
|
||||||
|
// curve is used for the animation to mimic the aesthetics of the native
|
||||||
|
// iOS cursor.
|
||||||
|
//
|
||||||
|
// These values and curves have been obtained through eyeballing, so are
|
||||||
|
// likely not exactly the same as the values for native iOS.
|
||||||
|
_blinkOpacityController.animateTo(targetOpacity, curve: Curves.easeOut);
|
||||||
|
} else {
|
||||||
|
_blinkOpacityController.value = targetOpacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _waitForStart(Timer timer) {
|
||||||
|
_cursorTimer?.cancel();
|
||||||
|
_cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
void startCursorTimer() {
|
||||||
|
if (_isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_targetCursorVisibility = true;
|
||||||
|
_blinkOpacityController.value = 1.0;
|
||||||
|
|
||||||
|
if (style.opacityAnimates) {
|
||||||
|
_cursorTimer = Timer.periodic(_blinkWaitForStart, _waitForStart);
|
||||||
|
} else {
|
||||||
|
_cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopCursorTimer({bool resetCharTicks = true}) {
|
||||||
|
_cursorTimer?.cancel();
|
||||||
|
_cursorTimer = null;
|
||||||
|
_targetCursorVisibility = false;
|
||||||
|
_blinkOpacityController.value = 0.0;
|
||||||
|
|
||||||
|
if (style.opacityAnimates) {
|
||||||
|
_blinkOpacityController
|
||||||
|
..stop()
|
||||||
|
..value = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) {
|
||||||
|
if (show.value &&
|
||||||
|
_cursorTimer == null &&
|
||||||
|
hasFocus &&
|
||||||
|
selection.isCollapsed) {
|
||||||
|
startCursorTimer();
|
||||||
|
} else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) {
|
||||||
|
stopCursorTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onColorTick() {
|
||||||
|
color.value = _style.color.withOpacity(_blinkOpacityController.value);
|
||||||
|
blink.value = show.value && _blinkOpacityController.value > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paints the editing cursor.
|
||||||
|
class CursorPainter {
|
||||||
|
CursorPainter(
|
||||||
|
this.editable,
|
||||||
|
this.style,
|
||||||
|
this.prototype,
|
||||||
|
this.color,
|
||||||
|
this.devicePixelRatio,
|
||||||
|
);
|
||||||
|
|
||||||
|
final RenderContentProxyBox? editable;
|
||||||
|
final CursorStyle style;
|
||||||
|
final Rect prototype;
|
||||||
|
final Color color;
|
||||||
|
final double devicePixelRatio;
|
||||||
|
|
||||||
|
/// Paints cursor on [canvas] at specified [position].
|
||||||
|
/// [offset] is global top left (x, y) of text line
|
||||||
|
/// [position] is relative (x) in text line
|
||||||
|
void paint(
|
||||||
|
Canvas canvas, Offset offset, TextPosition position, bool lineHasEmbed) {
|
||||||
|
// relative (x, y) to global offset
|
||||||
|
var relativeCaretOffset = editable!.getOffsetForCaret(position, prototype);
|
||||||
|
if (lineHasEmbed && relativeCaretOffset == Offset.zero) {
|
||||||
|
relativeCaretOffset = editable!.getOffsetForCaret(
|
||||||
|
TextPosition(
|
||||||
|
offset: position.offset - 1, affinity: position.affinity),
|
||||||
|
prototype);
|
||||||
|
// Hardcoded 6 as estimate of the width of a character
|
||||||
|
relativeCaretOffset =
|
||||||
|
Offset(relativeCaretOffset.dx + 6, relativeCaretOffset.dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
final caretOffset = relativeCaretOffset + offset;
|
||||||
|
var caretRect = prototype.shift(caretOffset);
|
||||||
|
if (style.offset != null) {
|
||||||
|
caretRect = caretRect.shift(style.offset!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caretRect.left < 0.0) {
|
||||||
|
// For iOS the cursor may get clipped by the scroll view when
|
||||||
|
// it's located at a beginning of a line. We ensure that this
|
||||||
|
// does not happen here. This may result in the cursor being painted
|
||||||
|
// closer to the character on the right, but it's arguably better
|
||||||
|
// then painting clipped cursor (or even cursor completely hidden).
|
||||||
|
caretRect = caretRect.shift(Offset(-caretRect.left, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
final caretHeight = editable!.getFullHeightForCaret(position);
|
||||||
|
if (caretHeight != null) {
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
case TargetPlatform.fuchsia:
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
// Override the height to take the full height of the glyph at the
|
||||||
|
// TextPosition when not on iOS. iOS has special handling that
|
||||||
|
// creates a taller caret.
|
||||||
|
caretRect = Rect.fromLTWH(
|
||||||
|
caretRect.left,
|
||||||
|
caretRect.top - 2.0,
|
||||||
|
caretRect.width,
|
||||||
|
caretHeight,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
// Center the caret vertically along the text.
|
||||||
|
caretRect = Rect.fromLTWH(
|
||||||
|
caretRect.left,
|
||||||
|
caretRect.top + (caretHeight - caretRect.height) / 2,
|
||||||
|
caretRect.width,
|
||||||
|
caretRect.height,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final pixelPerfectOffset = _getPixelPerfectCursorOffset(caretRect);
|
||||||
|
if (!pixelPerfectOffset.isFinite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
caretRect = caretRect.shift(pixelPerfectOffset);
|
||||||
|
|
||||||
|
final paint = Paint()..color = color;
|
||||||
|
if (style.radius == null) {
|
||||||
|
canvas.drawRect(caretRect, paint);
|
||||||
|
} else {
|
||||||
|
final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!);
|
||||||
|
canvas.drawRRect(caretRRect, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset _getPixelPerfectCursorOffset(
|
||||||
|
Rect caretRect,
|
||||||
|
) {
|
||||||
|
final caretPosition = editable!.localToGlobal(caretRect.topLeft);
|
||||||
|
final pixelMultiple = 1.0 / devicePixelRatio;
|
||||||
|
|
||||||
|
final pixelPerfectOffsetX = caretPosition.dx.isFinite
|
||||||
|
? (caretPosition.dx / pixelMultiple).round() * pixelMultiple -
|
||||||
|
caretPosition.dx
|
||||||
|
: caretPosition.dx;
|
||||||
|
final pixelPerfectOffsetY = caretPosition.dy.isFinite
|
||||||
|
? (caretPosition.dy / pixelMultiple).round() * pixelMultiple -
|
||||||
|
caretPosition.dy
|
||||||
|
: caretPosition.dy;
|
||||||
|
|
||||||
|
return Offset(pixelPerfectOffsetX, pixelPerfectOffsetY);
|
||||||
|
}
|
||||||
|
}
|
235
app_flowy/packages/editor/lib/src/widgets/default_styles.dart
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
class QuillStyles extends InheritedWidget {
|
||||||
|
const QuillStyles({
|
||||||
|
required this.data,
|
||||||
|
required Widget child,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key, child: child);
|
||||||
|
|
||||||
|
final DefaultStyles data;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(QuillStyles oldWidget) {
|
||||||
|
return data != oldWidget.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static DefaultStyles? getStyles(BuildContext context, bool nullOk) {
|
||||||
|
final widget = context.dependOnInheritedWidgetOfExactType<QuillStyles>();
|
||||||
|
if (widget == null && nullOk) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
assert(widget != null);
|
||||||
|
return widget!.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultTextBlockStyle {
|
||||||
|
DefaultTextBlockStyle(
|
||||||
|
this.style,
|
||||||
|
this.verticalSpacing,
|
||||||
|
this.lineSpacing,
|
||||||
|
this.decoration,
|
||||||
|
);
|
||||||
|
|
||||||
|
final TextStyle style;
|
||||||
|
|
||||||
|
final Tuple2<double, double> verticalSpacing;
|
||||||
|
|
||||||
|
final Tuple2<double, double> lineSpacing;
|
||||||
|
|
||||||
|
final BoxDecoration? decoration;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultStyles {
|
||||||
|
DefaultStyles({
|
||||||
|
this.h1,
|
||||||
|
this.h2,
|
||||||
|
this.h3,
|
||||||
|
this.paragraph,
|
||||||
|
this.bold,
|
||||||
|
this.italic,
|
||||||
|
this.small,
|
||||||
|
this.underline,
|
||||||
|
this.strikeThrough,
|
||||||
|
this.inlineCode,
|
||||||
|
this.link,
|
||||||
|
this.color,
|
||||||
|
this.placeHolder,
|
||||||
|
this.lists,
|
||||||
|
this.quote,
|
||||||
|
this.code,
|
||||||
|
this.indent,
|
||||||
|
this.align,
|
||||||
|
this.leading,
|
||||||
|
this.sizeSmall,
|
||||||
|
this.sizeLarge,
|
||||||
|
this.sizeHuge,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DefaultTextBlockStyle? h1;
|
||||||
|
final DefaultTextBlockStyle? h2;
|
||||||
|
final DefaultTextBlockStyle? h3;
|
||||||
|
final DefaultTextBlockStyle? paragraph;
|
||||||
|
final TextStyle? bold;
|
||||||
|
final TextStyle? italic;
|
||||||
|
final TextStyle? small;
|
||||||
|
final TextStyle? underline;
|
||||||
|
final TextStyle? strikeThrough;
|
||||||
|
final TextStyle? inlineCode;
|
||||||
|
final TextStyle? sizeSmall; // 'small'
|
||||||
|
final TextStyle? sizeLarge; // 'large'
|
||||||
|
final TextStyle? sizeHuge; // 'huge'
|
||||||
|
final TextStyle? link;
|
||||||
|
final Color? color;
|
||||||
|
final DefaultTextBlockStyle? placeHolder;
|
||||||
|
final DefaultTextBlockStyle? lists;
|
||||||
|
final DefaultTextBlockStyle? quote;
|
||||||
|
final DefaultTextBlockStyle? code;
|
||||||
|
final DefaultTextBlockStyle? indent;
|
||||||
|
final DefaultTextBlockStyle? align;
|
||||||
|
final DefaultTextBlockStyle? leading;
|
||||||
|
|
||||||
|
static DefaultStyles getInstance(BuildContext context) {
|
||||||
|
final themeData = Theme.of(context);
|
||||||
|
final defaultTextStyle = DefaultTextStyle.of(context);
|
||||||
|
final baseStyle = defaultTextStyle.style.copyWith(
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1.3,
|
||||||
|
);
|
||||||
|
const baseSpacing = Tuple2<double, double>(6, 0);
|
||||||
|
String fontFamily;
|
||||||
|
switch (themeData.platform) {
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
fontFamily = 'Menlo';
|
||||||
|
break;
|
||||||
|
case TargetPlatform.android:
|
||||||
|
case TargetPlatform.fuchsia:
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
fontFamily = 'Roboto Mono';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefaultStyles(
|
||||||
|
h1: DefaultTextBlockStyle(
|
||||||
|
defaultTextStyle.style.copyWith(
|
||||||
|
fontSize: 34,
|
||||||
|
color: defaultTextStyle.style.color!.withOpacity(0.70),
|
||||||
|
height: 1.15,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
),
|
||||||
|
const Tuple2(16, 0),
|
||||||
|
const Tuple2(0, 0),
|
||||||
|
null),
|
||||||
|
h2: DefaultTextBlockStyle(
|
||||||
|
defaultTextStyle.style.copyWith(
|
||||||
|
fontSize: 24,
|
||||||
|
color: defaultTextStyle.style.color!.withOpacity(0.70),
|
||||||
|
height: 1.15,
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
),
|
||||||
|
const Tuple2(8, 0),
|
||||||
|
const Tuple2(0, 0),
|
||||||
|
null),
|
||||||
|
h3: DefaultTextBlockStyle(
|
||||||
|
defaultTextStyle.style.copyWith(
|
||||||
|
fontSize: 20,
|
||||||
|
color: defaultTextStyle.style.color!.withOpacity(0.70),
|
||||||
|
height: 1.25,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
const Tuple2(8, 0),
|
||||||
|
const Tuple2(0, 0),
|
||||||
|
null),
|
||||||
|
paragraph: DefaultTextBlockStyle(
|
||||||
|
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
|
||||||
|
bold: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
italic: const TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
small: const TextStyle(fontSize: 12, color: Colors.black45),
|
||||||
|
underline: const TextStyle(decoration: TextDecoration.underline),
|
||||||
|
strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough),
|
||||||
|
inlineCode: TextStyle(
|
||||||
|
color: Colors.blue.shade900.withOpacity(0.9),
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
link: TextStyle(
|
||||||
|
color: themeData.colorScheme.secondary,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
placeHolder: DefaultTextBlockStyle(
|
||||||
|
defaultTextStyle.style.copyWith(
|
||||||
|
fontSize: 20,
|
||||||
|
height: 1.5,
|
||||||
|
color: Colors.grey.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
const Tuple2(0, 0),
|
||||||
|
const Tuple2(0, 0),
|
||||||
|
null),
|
||||||
|
lists: DefaultTextBlockStyle(
|
||||||
|
baseStyle, baseSpacing, const Tuple2(0, 6), null),
|
||||||
|
quote: DefaultTextBlockStyle(
|
||||||
|
TextStyle(color: baseStyle.color!.withOpacity(0.6)),
|
||||||
|
baseSpacing,
|
||||||
|
const Tuple2(6, 2),
|
||||||
|
BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(width: 4, color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
code: DefaultTextBlockStyle(
|
||||||
|
TextStyle(
|
||||||
|
color: Colors.blue.shade900.withOpacity(0.9),
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.15,
|
||||||
|
),
|
||||||
|
baseSpacing,
|
||||||
|
const Tuple2(0, 0),
|
||||||
|
BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
)),
|
||||||
|
indent: DefaultTextBlockStyle(
|
||||||
|
baseStyle, baseSpacing, const Tuple2(0, 6), null),
|
||||||
|
align: DefaultTextBlockStyle(
|
||||||
|
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
|
||||||
|
leading: DefaultTextBlockStyle(
|
||||||
|
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
|
||||||
|
sizeSmall: const TextStyle(fontSize: 10),
|
||||||
|
sizeLarge: const TextStyle(fontSize: 18),
|
||||||
|
sizeHuge: const TextStyle(fontSize: 22));
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultStyles merge(DefaultStyles other) {
|
||||||
|
return DefaultStyles(
|
||||||
|
h1: other.h1 ?? h1,
|
||||||
|
h2: other.h2 ?? h2,
|
||||||
|
h3: other.h3 ?? h3,
|
||||||
|
paragraph: other.paragraph ?? paragraph,
|
||||||
|
bold: other.bold ?? bold,
|
||||||
|
italic: other.italic ?? italic,
|
||||||
|
small: other.small ?? small,
|
||||||
|
underline: other.underline ?? underline,
|
||||||
|
strikeThrough: other.strikeThrough ?? strikeThrough,
|
||||||
|
inlineCode: other.inlineCode ?? inlineCode,
|
||||||
|
link: other.link ?? link,
|
||||||
|
color: other.color ?? color,
|
||||||
|
placeHolder: other.placeHolder ?? placeHolder,
|
||||||
|
lists: other.lists ?? lists,
|
||||||
|
quote: other.quote ?? quote,
|
||||||
|
code: other.code ?? code,
|
||||||
|
indent: other.indent ?? indent,
|
||||||
|
align: other.align ?? align,
|
||||||
|
leading: other.leading ?? leading,
|
||||||
|
sizeSmall: other.sizeSmall ?? sizeSmall,
|
||||||
|
sizeLarge: other.sizeLarge ?? sizeLarge,
|
||||||
|
sizeHuge: other.sizeHuge ?? sizeHuge);
|
||||||
|
}
|
||||||
|
}
|
152
app_flowy/packages/editor/lib/src/widgets/delegate.dart
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import '../../flutter_quill.dart';
|
||||||
|
|
||||||
|
import '../models/documents/nodes/leaf.dart';
|
||||||
|
import 'editor.dart';
|
||||||
|
import 'text_selection.dart';
|
||||||
|
|
||||||
|
typedef EmbedBuilder = Widget Function(
|
||||||
|
BuildContext context, Embed node, bool readOnly);
|
||||||
|
|
||||||
|
typedef CustomStyleBuilder = TextStyle Function(Attribute attribute);
|
||||||
|
|
||||||
|
abstract class EditorTextSelectionGestureDetectorBuilderDelegate {
|
||||||
|
GlobalKey<EditorState> getEditableTextKey();
|
||||||
|
|
||||||
|
bool getForcePressEnabled();
|
||||||
|
|
||||||
|
bool getSelectionEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
class EditorTextSelectionGestureDetectorBuilder {
|
||||||
|
EditorTextSelectionGestureDetectorBuilder(this.delegate);
|
||||||
|
|
||||||
|
final EditorTextSelectionGestureDetectorBuilderDelegate delegate;
|
||||||
|
bool shouldShowSelectionToolbar = true;
|
||||||
|
|
||||||
|
EditorState? getEditor() {
|
||||||
|
return delegate.getEditableTextKey().currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderEditor? getRenderEditor() {
|
||||||
|
return getEditor()!.getRenderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onTapDown(TapDownDetails details) {
|
||||||
|
getRenderEditor()!.handleTapDown(details);
|
||||||
|
|
||||||
|
final kind = details.kind;
|
||||||
|
shouldShowSelectionToolbar = kind == null ||
|
||||||
|
kind == PointerDeviceKind.touch ||
|
||||||
|
kind == PointerDeviceKind.stylus;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onForcePressStart(ForcePressDetails details) {
|
||||||
|
assert(delegate.getForcePressEnabled());
|
||||||
|
shouldShowSelectionToolbar = true;
|
||||||
|
if (delegate.getSelectionEnabled()) {
|
||||||
|
getRenderEditor()!.selectWordsInRange(
|
||||||
|
details.globalPosition,
|
||||||
|
null,
|
||||||
|
SelectionChangedCause.forcePress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onForcePressEnd(ForcePressDetails details) {
|
||||||
|
assert(delegate.getForcePressEnabled());
|
||||||
|
getRenderEditor()!.selectWordsInRange(
|
||||||
|
details.globalPosition,
|
||||||
|
null,
|
||||||
|
SelectionChangedCause.forcePress,
|
||||||
|
);
|
||||||
|
if (shouldShowSelectionToolbar) {
|
||||||
|
getEditor()!.showToolbar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSingleTapUp(TapUpDetails details) {
|
||||||
|
if (delegate.getSelectionEnabled()) {
|
||||||
|
getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSingleTapCancel() {}
|
||||||
|
|
||||||
|
void onSingleLongTapStart(LongPressStartDetails details) {
|
||||||
|
if (delegate.getSelectionEnabled()) {
|
||||||
|
getRenderEditor()!.selectPositionAt(
|
||||||
|
details.globalPosition,
|
||||||
|
null,
|
||||||
|
SelectionChangedCause.longPress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
|
||||||
|
if (delegate.getSelectionEnabled()) {
|
||||||
|
getRenderEditor()!.selectPositionAt(
|
||||||
|
details.globalPosition,
|
||||||
|
null,
|
||||||
|
SelectionChangedCause.longPress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSingleLongTapEnd(LongPressEndDetails details) {
|
||||||
|
if (shouldShowSelectionToolbar) {
|
||||||
|
getEditor()!.showToolbar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDoubleTapDown(TapDownDetails details) {
|
||||||
|
if (delegate.getSelectionEnabled()) {
|
||||||
|
getRenderEditor()!.selectWord(SelectionChangedCause.tap);
|
||||||
|
if (shouldShowSelectionToolbar) {
|
||||||
|
getEditor()!.showToolbar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDragSelectionStart(DragStartDetails details) {
|
||||||
|
getRenderEditor()!.selectPositionAt(
|
||||||
|
details.globalPosition,
|
||||||
|
null,
|
||||||
|
SelectionChangedCause.drag,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDragSelectionUpdate(
|
||||||
|
DragStartDetails startDetails, DragUpdateDetails updateDetails) {
|
||||||
|
getRenderEditor()!.selectPositionAt(
|
||||||
|
startDetails.globalPosition,
|
||||||
|
updateDetails.globalPosition,
|
||||||
|
SelectionChangedCause.drag,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDragSelectionEnd(DragEndDetails details) {}
|
||||||
|
|
||||||
|
Widget build(HitTestBehavior behavior, Widget child) {
|
||||||
|
return EditorTextSelectionGestureDetector(
|
||||||
|
onTapDown: onTapDown,
|
||||||
|
onForcePressStart:
|
||||||
|
delegate.getForcePressEnabled() ? onForcePressStart : null,
|
||||||
|
onForcePressEnd: delegate.getForcePressEnabled() ? onForcePressEnd : null,
|
||||||
|
onSingleTapUp: onSingleTapUp,
|
||||||
|
onSingleTapCancel: onSingleTapCancel,
|
||||||
|
onSingleLongTapStart: onSingleLongTapStart,
|
||||||
|
onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate,
|
||||||
|
onSingleLongTapEnd: onSingleLongTapEnd,
|
||||||
|
onDoubleTapDown: onDoubleTapDown,
|
||||||
|
onDragSelectionStart: onDragSelectionStart,
|
||||||
|
onDragSelectionUpdate: onDragSelectionUpdate,
|
||||||
|
onDragSelectionEnd: onDragSelectionEnd,
|
||||||
|
behavior: behavior,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
1289
app_flowy/packages/editor/lib/src/widgets/editor.dart
Normal file
31
app_flowy/packages/editor/lib/src/widgets/image.dart
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
|
||||||
|
class ImageTapWrapper extends StatelessWidget {
|
||||||
|
const ImageTapWrapper({
|
||||||
|
this.imageProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ImageProvider? imageProvider;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
constraints: BoxConstraints.expand(
|
||||||
|
height: MediaQuery.of(context).size.height,
|
||||||
|
),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTapDown: (_) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: PhotoView(
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
129
app_flowy/packages/editor/lib/src/widgets/keyboard_listener.dart
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
//fixme workaround flutter MacOS issue https://github.com/flutter/flutter/issues/75595
|
||||||
|
extension _LogicalKeyboardKeyCaseExt on LogicalKeyboardKey {
|
||||||
|
static const _kUpperToLowerDist = 0x20;
|
||||||
|
static final _kLowerCaseA = LogicalKeyboardKey.keyA.keyId;
|
||||||
|
static final _kLowerCaseZ = LogicalKeyboardKey.keyZ.keyId;
|
||||||
|
|
||||||
|
LogicalKeyboardKey toUpperCase() {
|
||||||
|
if (keyId < _kLowerCaseA || keyId > _kLowerCaseZ) return this;
|
||||||
|
return LogicalKeyboardKey(keyId - _kUpperToLowerDist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InputShortcut { CUT, COPY, PASTE, SELECT_ALL, UNDO, REDO }
|
||||||
|
|
||||||
|
typedef CursorMoveCallback = void Function(
|
||||||
|
LogicalKeyboardKey key, bool wordModifier, bool lineModifier, bool shift);
|
||||||
|
typedef InputShortcutCallback = void Function(InputShortcut? shortcut);
|
||||||
|
typedef OnDeleteCallback = void Function(bool forward);
|
||||||
|
|
||||||
|
class KeyboardEventHandler {
|
||||||
|
KeyboardEventHandler(this.onCursorMove, this.onShortcut, this.onDelete);
|
||||||
|
|
||||||
|
final CursorMoveCallback onCursorMove;
|
||||||
|
final InputShortcutCallback onShortcut;
|
||||||
|
final OnDeleteCallback onDelete;
|
||||||
|
|
||||||
|
static final Set<LogicalKeyboardKey> _moveKeys = <LogicalKeyboardKey>{
|
||||||
|
LogicalKeyboardKey.arrowRight,
|
||||||
|
LogicalKeyboardKey.arrowLeft,
|
||||||
|
LogicalKeyboardKey.arrowUp,
|
||||||
|
LogicalKeyboardKey.arrowDown,
|
||||||
|
};
|
||||||
|
|
||||||
|
static final Set<LogicalKeyboardKey> _shortcutKeys = <LogicalKeyboardKey>{
|
||||||
|
LogicalKeyboardKey.keyA,
|
||||||
|
LogicalKeyboardKey.keyC,
|
||||||
|
LogicalKeyboardKey.keyV,
|
||||||
|
LogicalKeyboardKey.keyX,
|
||||||
|
LogicalKeyboardKey.keyZ.toUpperCase(),
|
||||||
|
LogicalKeyboardKey.keyZ,
|
||||||
|
LogicalKeyboardKey.delete,
|
||||||
|
LogicalKeyboardKey.backspace,
|
||||||
|
};
|
||||||
|
|
||||||
|
static final Set<LogicalKeyboardKey> _nonModifierKeys = <LogicalKeyboardKey>{
|
||||||
|
..._shortcutKeys,
|
||||||
|
..._moveKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{
|
||||||
|
LogicalKeyboardKey.shift,
|
||||||
|
LogicalKeyboardKey.control,
|
||||||
|
LogicalKeyboardKey.alt,
|
||||||
|
};
|
||||||
|
|
||||||
|
static final Set<LogicalKeyboardKey> _macOsModifierKeys =
|
||||||
|
<LogicalKeyboardKey>{
|
||||||
|
LogicalKeyboardKey.shift,
|
||||||
|
LogicalKeyboardKey.meta,
|
||||||
|
LogicalKeyboardKey.alt,
|
||||||
|
};
|
||||||
|
|
||||||
|
static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{
|
||||||
|
..._modifierKeys,
|
||||||
|
..._macOsModifierKeys,
|
||||||
|
..._nonModifierKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
static final Map<LogicalKeyboardKey, InputShortcut> _keyToShortcut = {
|
||||||
|
LogicalKeyboardKey.keyX: InputShortcut.CUT,
|
||||||
|
LogicalKeyboardKey.keyC: InputShortcut.COPY,
|
||||||
|
LogicalKeyboardKey.keyV: InputShortcut.PASTE,
|
||||||
|
LogicalKeyboardKey.keyA: InputShortcut.SELECT_ALL,
|
||||||
|
};
|
||||||
|
|
||||||
|
KeyEventResult handleRawKeyEvent(RawKeyEvent event) {
|
||||||
|
if (kIsWeb) {
|
||||||
|
// On web platform, we ignore the key because it's already processed.
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event is! RawKeyDownEvent) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
final keysPressed =
|
||||||
|
LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed);
|
||||||
|
final key = event.logicalKey;
|
||||||
|
final isMacOS = event.data is RawKeyEventDataMacOs;
|
||||||
|
if (!_nonModifierKeys.contains(key) ||
|
||||||
|
keysPressed
|
||||||
|
.difference(isMacOS ? _macOsModifierKeys : _modifierKeys)
|
||||||
|
.length >
|
||||||
|
1 ||
|
||||||
|
keysPressed.difference(_interestingKeys).isNotEmpty) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isShortcutModifierPressed =
|
||||||
|
isMacOS ? event.isMetaPressed : event.isControlPressed;
|
||||||
|
|
||||||
|
if (_moveKeys.contains(key)) {
|
||||||
|
onCursorMove(
|
||||||
|
key,
|
||||||
|
isMacOS ? event.isAltPressed : event.isControlPressed,
|
||||||
|
isMacOS ? event.isMetaPressed : event.isAltPressed,
|
||||||
|
event.isShiftPressed);
|
||||||
|
} else if (isShortcutModifierPressed && (_shortcutKeys.contains(key))) {
|
||||||
|
if (key == LogicalKeyboardKey.keyZ ||
|
||||||
|
key == LogicalKeyboardKey.keyZ.toUpperCase()) {
|
||||||
|
onShortcut(
|
||||||
|
event.isShiftPressed ? InputShortcut.REDO : InputShortcut.UNDO);
|
||||||
|
} else {
|
||||||
|
onShortcut(_keyToShortcut[key]);
|
||||||
|
}
|
||||||
|
} else if (key == LogicalKeyboardKey.delete) {
|
||||||
|
onDelete(true);
|
||||||
|
} else if (key == LogicalKeyboardKey.backspace) {
|
||||||
|
onDelete(false);
|
||||||
|
} else {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
}
|
39
app_flowy/packages/editor/lib/src/widgets/link_dialog.dart
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class LinkDialog extends StatefulWidget {
|
||||||
|
const LinkDialog({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
LinkDialogState createState() => LinkDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class LinkDialogState extends State<LinkDialog> {
|
||||||
|
String _link = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
content: TextField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Paste a link'),
|
||||||
|
autofocus: true,
|
||||||
|
onChanged: _linkChanged,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _link.isNotEmpty ? _applyLink : null,
|
||||||
|
child: const Text('Ok'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _linkChanged(String value) {
|
||||||
|
setState(() {
|
||||||
|
_link = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyLink() {
|
||||||
|
Navigator.pop(context, _link);
|
||||||
|
}
|
||||||
|
}
|
303
app_flowy/packages/editor/lib/src/widgets/proxy.dart
Normal file
|
@ -0,0 +1,303 @@
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'box.dart';
|
||||||
|
|
||||||
|
class BaselineProxy extends SingleChildRenderObjectWidget {
|
||||||
|
const BaselineProxy({Key? key, Widget? child, this.textStyle, this.padding})
|
||||||
|
: super(key: key, child: child);
|
||||||
|
|
||||||
|
final TextStyle? textStyle;
|
||||||
|
final EdgeInsets? padding;
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderBaselineProxy createRenderObject(BuildContext context) {
|
||||||
|
return RenderBaselineProxy(
|
||||||
|
null,
|
||||||
|
textStyle!,
|
||||||
|
padding,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(
|
||||||
|
BuildContext context, covariant RenderBaselineProxy renderObject) {
|
||||||
|
renderObject
|
||||||
|
..textStyle = textStyle!
|
||||||
|
..padding = padding!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RenderBaselineProxy extends RenderProxyBox {
|
||||||
|
RenderBaselineProxy(
|
||||||
|
RenderParagraph? child,
|
||||||
|
TextStyle textStyle,
|
||||||
|
EdgeInsets? padding,
|
||||||
|
) : _prototypePainter = TextPainter(
|
||||||
|
text: TextSpan(text: ' ', style: textStyle),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
strutStyle:
|
||||||
|
StrutStyle.fromTextStyle(textStyle, forceStrutHeight: true)),
|
||||||
|
super(child);
|
||||||
|
|
||||||
|
final TextPainter _prototypePainter;
|
||||||
|
|
||||||
|
set textStyle(TextStyle value) {
|
||||||
|
if (_prototypePainter.text!.style == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prototypePainter.text = TextSpan(text: ' ', style: value);
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
EdgeInsets? _padding;
|
||||||
|
|
||||||
|
set padding(EdgeInsets value) {
|
||||||
|
if (_padding == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_padding = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double computeDistanceToActualBaseline(TextBaseline baseline) =>
|
||||||
|
_prototypePainter.computeDistanceToActualBaseline(baseline);
|
||||||
|
// SEE What happens + _padding?.top;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void performLayout() {
|
||||||
|
super.performLayout();
|
||||||
|
_prototypePainter.layout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmbedProxy extends SingleChildRenderObjectWidget {
|
||||||
|
const EmbedProxy(Widget child) : super(child: child);
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderEmbedProxy createRenderObject(BuildContext context) =>
|
||||||
|
RenderEmbedProxy(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox {
|
||||||
|
RenderEmbedProxy(RenderBox? child) : super(child);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<TextBox> getBoxesForSelection(TextSelection selection) {
|
||||||
|
if (!selection.isCollapsed) {
|
||||||
|
return <TextBox>[
|
||||||
|
TextBox.fromLTRBD(0, 0, size.width, size.height, TextDirection.ltr)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
final left = selection.extentOffset == 0 ? 0.0 : size.width;
|
||||||
|
final right = selection.extentOffset == 0 ? 0.0 : size.width;
|
||||||
|
return <TextBox>[
|
||||||
|
TextBox.fromLTRBD(left, 0, right, size.height, TextDirection.ltr)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double getFullHeightForCaret(TextPosition position) => size.height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) {
|
||||||
|
assert(
|
||||||
|
position.offset == 1 || position.offset == 0 || position.offset == -1);
|
||||||
|
return position.offset <= 0
|
||||||
|
? Offset.zero
|
||||||
|
: Offset(
|
||||||
|
size.width - (caretPrototype == null ? 0 : caretPrototype.width),
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextPosition getPositionForOffset(Offset offset) =>
|
||||||
|
TextPosition(offset: offset.dx > size.width / 2 ? 1 : 0);
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextRange getWordBoundary(TextPosition position) =>
|
||||||
|
const TextRange(start: 0, end: 1);
|
||||||
|
|
||||||
|
@override
|
||||||
|
double getPreferredLineHeight() {
|
||||||
|
return size.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RichTextProxy extends SingleChildRenderObjectWidget {
|
||||||
|
const RichTextProxy(
|
||||||
|
RichText child,
|
||||||
|
this.textStyle,
|
||||||
|
this.textAlign,
|
||||||
|
this.textDirection,
|
||||||
|
this.textScaleFactor,
|
||||||
|
this.locale,
|
||||||
|
this.strutStyle,
|
||||||
|
this.textWidthBasis,
|
||||||
|
this.textHeightBehavior,
|
||||||
|
) : super(child: child);
|
||||||
|
|
||||||
|
final TextStyle textStyle;
|
||||||
|
final TextAlign textAlign;
|
||||||
|
final TextDirection textDirection;
|
||||||
|
final double textScaleFactor;
|
||||||
|
final Locale locale;
|
||||||
|
final StrutStyle strutStyle;
|
||||||
|
final TextWidthBasis textWidthBasis;
|
||||||
|
final TextHeightBehavior? textHeightBehavior;
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderParagraphProxy createRenderObject(BuildContext context) {
|
||||||
|
return RenderParagraphProxy(
|
||||||
|
null,
|
||||||
|
textStyle,
|
||||||
|
textAlign,
|
||||||
|
textDirection,
|
||||||
|
textScaleFactor,
|
||||||
|
strutStyle,
|
||||||
|
locale,
|
||||||
|
textWidthBasis,
|
||||||
|
textHeightBehavior);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(
|
||||||
|
BuildContext context, covariant RenderParagraphProxy renderObject) {
|
||||||
|
renderObject
|
||||||
|
..textStyle = textStyle
|
||||||
|
..textAlign = textAlign
|
||||||
|
..textDirection = textDirection
|
||||||
|
..textScaleFactor = textScaleFactor
|
||||||
|
..locale = locale
|
||||||
|
..strutStyle = strutStyle
|
||||||
|
..textWidthBasis = textWidthBasis
|
||||||
|
..textHeightBehavior = textHeightBehavior;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RenderParagraphProxy extends RenderProxyBox
|
||||||
|
implements RenderContentProxyBox {
|
||||||
|
RenderParagraphProxy(
|
||||||
|
RenderParagraph? child,
|
||||||
|
TextStyle textStyle,
|
||||||
|
TextAlign textAlign,
|
||||||
|
TextDirection textDirection,
|
||||||
|
double textScaleFactor,
|
||||||
|
StrutStyle strutStyle,
|
||||||
|
Locale locale,
|
||||||
|
TextWidthBasis textWidthBasis,
|
||||||
|
TextHeightBehavior? textHeightBehavior,
|
||||||
|
) : _prototypePainter = TextPainter(
|
||||||
|
text: TextSpan(text: ' ', style: textStyle),
|
||||||
|
textAlign: textAlign,
|
||||||
|
textDirection: textDirection,
|
||||||
|
textScaleFactor: textScaleFactor,
|
||||||
|
strutStyle: strutStyle,
|
||||||
|
locale: locale,
|
||||||
|
textWidthBasis: textWidthBasis,
|
||||||
|
textHeightBehavior: textHeightBehavior),
|
||||||
|
super(child);
|
||||||
|
|
||||||
|
final TextPainter _prototypePainter;
|
||||||
|
|
||||||
|
set textStyle(TextStyle value) {
|
||||||
|
if (_prototypePainter.text!.style == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prototypePainter.text = TextSpan(text: ' ', style: value);
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
set textAlign(TextAlign value) {
|
||||||
|
if (_prototypePainter.textAlign == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prototypePainter.textAlign = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
set textDirection(TextDirection value) {
|
||||||
|
if (_prototypePainter.textDirection == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prototypePainter.textDirection = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
set textScaleFactor(double value) {
|
||||||
|
if (_prototypePainter.textScaleFactor == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prototypePainter.textScaleFactor = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
set strutStyle(StrutStyle value) {
|
||||||
|
if (_prototypePainter.strutStyle == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prototypePainter.strutStyle = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
set locale(Locale value) {
|
||||||
|
if (_prototypePainter.locale == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prototypePainter.locale = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
set textWidthBasis(TextWidthBasis value) {
|
||||||
|
if (_prototypePainter.textWidthBasis == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prototypePainter.textWidthBasis = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
set textHeightBehavior(TextHeightBehavior? value) {
|
||||||
|
if (_prototypePainter.textHeightBehavior == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_prototypePainter.textHeightBehavior = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderParagraph? get child => super.child as RenderParagraph?;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double getPreferredLineHeight() {
|
||||||
|
return _prototypePainter.preferredLineHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) =>
|
||||||
|
child!.getOffsetForCaret(position, caretPrototype!);
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextPosition getPositionForOffset(Offset offset) =>
|
||||||
|
child!.getPositionForOffset(offset);
|
||||||
|
|
||||||
|
@override
|
||||||
|
double? getFullHeightForCaret(TextPosition position) =>
|
||||||
|
child!.getFullHeightForCaret(position);
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextRange getWordBoundary(TextPosition position) =>
|
||||||
|
child!.getWordBoundary(position);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<TextBox> getBoxesForSelection(TextSelection selection) =>
|
||||||
|
child!.getBoxesForSelection(selection);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void performLayout() {
|
||||||
|
super.performLayout();
|
||||||
|
_prototypePainter.layout(
|
||||||
|
minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
||||||
|
}
|
||||||
|
}
|
764
app_flowy/packages/editor/lib/src/widgets/raw_editor.dart
Normal file
|
@ -0,0 +1,764 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
import '../models/documents/attribute.dart';
|
||||||
|
import '../models/documents/document.dart';
|
||||||
|
import '../models/documents/nodes/block.dart';
|
||||||
|
import '../models/documents/nodes/line.dart';
|
||||||
|
import 'controller.dart';
|
||||||
|
import 'cursor.dart';
|
||||||
|
import 'default_styles.dart';
|
||||||
|
import 'delegate.dart';
|
||||||
|
import 'editor.dart';
|
||||||
|
import 'keyboard_listener.dart';
|
||||||
|
import 'proxy.dart';
|
||||||
|
import 'raw_editor/raw_editor_state_keyboard_mixin.dart';
|
||||||
|
import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart';
|
||||||
|
import 'raw_editor/raw_editor_state_text_input_client_mixin.dart';
|
||||||
|
import 'text_block.dart';
|
||||||
|
import 'text_line.dart';
|
||||||
|
import 'text_selection.dart';
|
||||||
|
|
||||||
|
class RawEditor extends StatefulWidget {
|
||||||
|
const RawEditor(
|
||||||
|
Key key,
|
||||||
|
this.controller,
|
||||||
|
this.focusNode,
|
||||||
|
this.scrollController,
|
||||||
|
this.scrollable,
|
||||||
|
this.scrollBottomInset,
|
||||||
|
this.padding,
|
||||||
|
this.readOnly,
|
||||||
|
this.placeholder,
|
||||||
|
this.onLaunchUrl,
|
||||||
|
this.toolbarOptions,
|
||||||
|
this.showSelectionHandles,
|
||||||
|
bool? showCursor,
|
||||||
|
this.cursorStyle,
|
||||||
|
this.textCapitalization,
|
||||||
|
this.maxHeight,
|
||||||
|
this.minHeight,
|
||||||
|
this.customStyles,
|
||||||
|
this.expands,
|
||||||
|
this.autoFocus,
|
||||||
|
this.selectionColor,
|
||||||
|
this.selectionCtrls,
|
||||||
|
this.keyboardAppearance,
|
||||||
|
this.enableInteractiveSelection,
|
||||||
|
this.scrollPhysics,
|
||||||
|
this.embedBuilder,
|
||||||
|
this.customStyleBuilder,
|
||||||
|
) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
|
||||||
|
assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'),
|
||||||
|
assert(maxHeight == null || minHeight == null || maxHeight >= minHeight,
|
||||||
|
'maxHeight cannot be null'),
|
||||||
|
showCursor = showCursor ?? true,
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
final QuillController controller;
|
||||||
|
final FocusNode focusNode;
|
||||||
|
final ScrollController scrollController;
|
||||||
|
final bool scrollable;
|
||||||
|
final double scrollBottomInset;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
final bool readOnly;
|
||||||
|
final String? placeholder;
|
||||||
|
final ValueChanged<String>? onLaunchUrl;
|
||||||
|
final ToolbarOptions toolbarOptions;
|
||||||
|
final bool showSelectionHandles;
|
||||||
|
final bool showCursor;
|
||||||
|
final CursorStyle cursorStyle;
|
||||||
|
final TextCapitalization textCapitalization;
|
||||||
|
final double? maxHeight;
|
||||||
|
final double? minHeight;
|
||||||
|
final DefaultStyles? customStyles;
|
||||||
|
final bool expands;
|
||||||
|
final bool autoFocus;
|
||||||
|
final Color selectionColor;
|
||||||
|
final TextSelectionControls selectionCtrls;
|
||||||
|
final Brightness keyboardAppearance;
|
||||||
|
final bool enableInteractiveSelection;
|
||||||
|
final ScrollPhysics? scrollPhysics;
|
||||||
|
final EmbedBuilder embedBuilder;
|
||||||
|
final CustomStyleBuilder? customStyleBuilder;
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => RawEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class RawEditorState extends EditorState
|
||||||
|
with
|
||||||
|
AutomaticKeepAliveClientMixin<RawEditor>,
|
||||||
|
WidgetsBindingObserver,
|
||||||
|
TickerProviderStateMixin<RawEditor>,
|
||||||
|
RawEditorStateKeyboardMixin,
|
||||||
|
RawEditorStateTextInputClientMixin,
|
||||||
|
RawEditorStateSelectionDelegateMixin {
|
||||||
|
final GlobalKey _editorKey = GlobalKey();
|
||||||
|
|
||||||
|
// Keyboard
|
||||||
|
late KeyboardEventHandler _keyboardListener;
|
||||||
|
KeyboardVisibilityController? _keyboardVisibilityController;
|
||||||
|
StreamSubscription<bool>? _keyboardVisibilitySubscription;
|
||||||
|
bool _keyboardVisible = false;
|
||||||
|
|
||||||
|
// Selection overlay
|
||||||
|
@override
|
||||||
|
EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay;
|
||||||
|
EditorTextSelectionOverlay? _selectionOverlay;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScrollController get scrollController => _scrollController;
|
||||||
|
late ScrollController _scrollController;
|
||||||
|
|
||||||
|
late CursorCont _cursorCont;
|
||||||
|
|
||||||
|
// Focus
|
||||||
|
bool _didAutoFocus = false;
|
||||||
|
FocusAttachment? _focusAttachment;
|
||||||
|
bool get _hasFocus => widget.focusNode.hasFocus;
|
||||||
|
|
||||||
|
DefaultStyles? _styles;
|
||||||
|
|
||||||
|
final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier();
|
||||||
|
final LayerLink _toolbarLayerLink = LayerLink();
|
||||||
|
final LayerLink _startHandleLayerLink = LayerLink();
|
||||||
|
final LayerLink _endHandleLayerLink = LayerLink();
|
||||||
|
|
||||||
|
TextDirection get _textDirection => Directionality.of(context);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
assert(debugCheckHasMediaQuery(context));
|
||||||
|
_focusAttachment!.reparent();
|
||||||
|
super.build(context);
|
||||||
|
|
||||||
|
var _doc = widget.controller.document;
|
||||||
|
if (_doc.isEmpty() && widget.placeholder != null) {
|
||||||
|
_doc = Document.fromJson(jsonDecode(
|
||||||
|
'[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget child = CompositedTransformTarget(
|
||||||
|
link: _toolbarLayerLink,
|
||||||
|
child: Semantics(
|
||||||
|
child: _Editor(
|
||||||
|
key: _editorKey,
|
||||||
|
document: _doc,
|
||||||
|
selection: widget.controller.selection,
|
||||||
|
hasFocus: _hasFocus,
|
||||||
|
textDirection: _textDirection,
|
||||||
|
startHandleLayerLink: _startHandleLayerLink,
|
||||||
|
endHandleLayerLink: _endHandleLayerLink,
|
||||||
|
onSelectionChanged: _handleSelectionChanged,
|
||||||
|
scrollBottomInset: widget.scrollBottomInset,
|
||||||
|
padding: widget.padding,
|
||||||
|
children: _buildChildren(_doc, context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.scrollable) {
|
||||||
|
final baselinePadding =
|
||||||
|
EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.item1);
|
||||||
|
child = BaselineProxy(
|
||||||
|
textStyle: _styles!.paragraph!.style,
|
||||||
|
padding: baselinePadding,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
physics: widget.scrollPhysics,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final constraints = widget.expands
|
||||||
|
? const BoxConstraints.expand()
|
||||||
|
: BoxConstraints(
|
||||||
|
minHeight: widget.minHeight ?? 0.0,
|
||||||
|
maxHeight: widget.maxHeight ?? double.infinity);
|
||||||
|
|
||||||
|
return QuillStyles(
|
||||||
|
data: _styles!,
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.text,
|
||||||
|
child: Container(
|
||||||
|
constraints: constraints,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSelectionChanged(
|
||||||
|
TextSelection selection, SelectionChangedCause cause) {
|
||||||
|
widget.controller.updateSelection(selection, ChangeSource.LOCAL);
|
||||||
|
|
||||||
|
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
|
||||||
|
|
||||||
|
if (!_keyboardVisible) {
|
||||||
|
requestKeyboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the checkbox positioned at [offset] in document
|
||||||
|
/// by changing its attribute according to [value].
|
||||||
|
void _handleCheckboxTap(int offset, bool value) {
|
||||||
|
if (!widget.readOnly) {
|
||||||
|
if (value) {
|
||||||
|
widget.controller.formatText(offset, 0, Attribute.checked);
|
||||||
|
} else {
|
||||||
|
widget.controller.formatText(offset, 0, Attribute.unchecked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildChildren(Document doc, BuildContext context) {
|
||||||
|
final result = <Widget>[];
|
||||||
|
final indentLevelCounts = <int, int>{};
|
||||||
|
for (final node in doc.root.children) {
|
||||||
|
if (node is Line) {
|
||||||
|
final editableTextLine = _getEditableTextLineFromNode(node, context);
|
||||||
|
result.add(editableTextLine);
|
||||||
|
} else if (node is Block) {
|
||||||
|
final attrs = node.style.attributes;
|
||||||
|
final editableTextBlock = EditableTextBlock(
|
||||||
|
block: node,
|
||||||
|
textDirection: _textDirection,
|
||||||
|
scrollBottomInset: widget.scrollBottomInset,
|
||||||
|
verticalSpacing: _getVerticalSpacingForBlock(node, _styles),
|
||||||
|
textSelection: widget.controller.selection,
|
||||||
|
color: widget.selectionColor,
|
||||||
|
styles: _styles,
|
||||||
|
enableInteractiveSelection: widget.enableInteractiveSelection,
|
||||||
|
hasFocus: _hasFocus,
|
||||||
|
contentPadding: attrs.containsKey(Attribute.codeBlock.key)
|
||||||
|
? const EdgeInsets.all(16)
|
||||||
|
: null,
|
||||||
|
embedBuilder: widget.embedBuilder,
|
||||||
|
cursorCont: _cursorCont,
|
||||||
|
indentLevelCounts: indentLevelCounts,
|
||||||
|
onCheckboxTap: _handleCheckboxTap,
|
||||||
|
readOnly: widget.readOnly,
|
||||||
|
customStyleBuilder: widget.customStyleBuilder);
|
||||||
|
result.add(editableTextBlock);
|
||||||
|
} else {
|
||||||
|
throw StateError('Unreachable.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditableTextLine _getEditableTextLineFromNode(
|
||||||
|
Line node, BuildContext context) {
|
||||||
|
final textLine = TextLine(
|
||||||
|
line: node,
|
||||||
|
textDirection: _textDirection,
|
||||||
|
embedBuilder: widget.embedBuilder,
|
||||||
|
customStyleBuilder: widget.customStyleBuilder,
|
||||||
|
styles: _styles!,
|
||||||
|
readOnly: widget.readOnly,
|
||||||
|
);
|
||||||
|
final editableTextLine = EditableTextLine(
|
||||||
|
node,
|
||||||
|
null,
|
||||||
|
textLine,
|
||||||
|
0,
|
||||||
|
_getVerticalSpacingForLine(node, _styles),
|
||||||
|
_textDirection,
|
||||||
|
widget.controller.selection,
|
||||||
|
widget.selectionColor,
|
||||||
|
widget.enableInteractiveSelection,
|
||||||
|
_hasFocus,
|
||||||
|
MediaQuery.of(context).devicePixelRatio,
|
||||||
|
_cursorCont);
|
||||||
|
return editableTextLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2<double, double> _getVerticalSpacingForLine(
|
||||||
|
Line line, DefaultStyles? defaultStyles) {
|
||||||
|
final attrs = line.style.attributes;
|
||||||
|
if (attrs.containsKey(Attribute.header.key)) {
|
||||||
|
final int? level = attrs[Attribute.header.key]!.value;
|
||||||
|
switch (level) {
|
||||||
|
case 1:
|
||||||
|
return defaultStyles!.h1!.verticalSpacing;
|
||||||
|
case 2:
|
||||||
|
return defaultStyles!.h2!.verticalSpacing;
|
||||||
|
case 3:
|
||||||
|
return defaultStyles!.h3!.verticalSpacing;
|
||||||
|
default:
|
||||||
|
throw 'Invalid level $level';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultStyles!.paragraph!.verticalSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2<double, double> _getVerticalSpacingForBlock(
|
||||||
|
Block node, DefaultStyles? defaultStyles) {
|
||||||
|
final attrs = node.style.attributes;
|
||||||
|
if (attrs.containsKey(Attribute.blockQuote.key)) {
|
||||||
|
return defaultStyles!.quote!.verticalSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.codeBlock.key)) {
|
||||||
|
return defaultStyles!.code!.verticalSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.indent.key)) {
|
||||||
|
return defaultStyles!.indent!.verticalSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.list.key)) {
|
||||||
|
return defaultStyles!.lists!.verticalSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.align.key)) {
|
||||||
|
return defaultStyles!.align!.verticalSpacing;
|
||||||
|
}
|
||||||
|
return const Tuple2(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_clipboardStatus.addListener(_onChangedClipboardStatus);
|
||||||
|
|
||||||
|
widget.controller.addListener(() {
|
||||||
|
_didChangeTextEditingValue(widget.controller.ignoreFocusOnTextChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
_scrollController = widget.scrollController;
|
||||||
|
_scrollController.addListener(_updateSelectionOverlayForScroll);
|
||||||
|
|
||||||
|
_cursorCont = CursorCont(
|
||||||
|
show: ValueNotifier<bool>(widget.showCursor),
|
||||||
|
style: widget.cursorStyle,
|
||||||
|
tickerProvider: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_keyboardListener = KeyboardEventHandler(
|
||||||
|
handleCursorMovement,
|
||||||
|
handleShortcut,
|
||||||
|
handleDelete,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.windows ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.linux ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.fuchsia) {
|
||||||
|
_keyboardVisible = true;
|
||||||
|
} else {
|
||||||
|
_keyboardVisibilityController = KeyboardVisibilityController();
|
||||||
|
_keyboardVisible = _keyboardVisibilityController!.isVisible;
|
||||||
|
_keyboardVisibilitySubscription =
|
||||||
|
_keyboardVisibilityController?.onChange.listen((visible) {
|
||||||
|
_keyboardVisible = visible;
|
||||||
|
if (visible) {
|
||||||
|
_onChangeTextEditingValue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_focusAttachment = widget.focusNode.attach(context,
|
||||||
|
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
|
||||||
|
widget.focusNode.addListener(_handleFocusChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final parentStyles = QuillStyles.getStyles(context, true);
|
||||||
|
final defaultStyles = DefaultStyles.getInstance(context);
|
||||||
|
_styles = (parentStyles != null)
|
||||||
|
? defaultStyles.merge(parentStyles)
|
||||||
|
: defaultStyles;
|
||||||
|
|
||||||
|
if (widget.customStyles != null) {
|
||||||
|
_styles = _styles!.merge(widget.customStyles!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_didAutoFocus && widget.autoFocus) {
|
||||||
|
FocusScope.of(context).autofocus(widget.focusNode);
|
||||||
|
_didAutoFocus = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(RawEditor oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
_cursorCont.show.value = widget.showCursor;
|
||||||
|
_cursorCont.style = widget.cursorStyle;
|
||||||
|
|
||||||
|
if (widget.controller != oldWidget.controller) {
|
||||||
|
oldWidget.controller.removeListener(_didChangeTextEditingValue);
|
||||||
|
widget.controller.addListener(_didChangeTextEditingValue);
|
||||||
|
updateRemoteValueIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.scrollController != _scrollController) {
|
||||||
|
_scrollController.removeListener(_updateSelectionOverlayForScroll);
|
||||||
|
_scrollController = widget.scrollController;
|
||||||
|
_scrollController.addListener(_updateSelectionOverlayForScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.focusNode != oldWidget.focusNode) {
|
||||||
|
oldWidget.focusNode.removeListener(_handleFocusChanged);
|
||||||
|
_focusAttachment?.detach();
|
||||||
|
_focusAttachment = widget.focusNode.attach(context,
|
||||||
|
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
|
||||||
|
widget.focusNode.addListener(_handleFocusChanged);
|
||||||
|
updateKeepAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.controller.selection != oldWidget.controller.selection) {
|
||||||
|
_selectionOverlay?.update(textEditingValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
|
||||||
|
if (!shouldCreateInputConnection) {
|
||||||
|
closeConnectionIfNeeded();
|
||||||
|
} else {
|
||||||
|
if (oldWidget.readOnly && _hasFocus) {
|
||||||
|
openConnectionIfNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldShowSelectionHandles() {
|
||||||
|
return widget.showSelectionHandles &&
|
||||||
|
!widget.controller.selection.isCollapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
closeConnectionIfNeeded();
|
||||||
|
_keyboardVisibilitySubscription?.cancel();
|
||||||
|
assert(!hasConnection);
|
||||||
|
_selectionOverlay?.dispose();
|
||||||
|
_selectionOverlay = null;
|
||||||
|
widget.controller.removeListener(_didChangeTextEditingValue);
|
||||||
|
widget.focusNode.removeListener(_handleFocusChanged);
|
||||||
|
_focusAttachment!.detach();
|
||||||
|
_cursorCont.dispose();
|
||||||
|
_clipboardStatus
|
||||||
|
..removeListener(_onChangedClipboardStatus)
|
||||||
|
..dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSelectionOverlayForScroll() {
|
||||||
|
_selectionOverlay?.markNeedsBuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _didChangeTextEditingValue([bool ignoreFocus = false]) {
|
||||||
|
if (kIsWeb) {
|
||||||
|
_onChangeTextEditingValue(ignoreFocus);
|
||||||
|
if (!ignoreFocus) {
|
||||||
|
requestKeyboard();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ignoreFocus || _keyboardVisible) {
|
||||||
|
_onChangeTextEditingValue(ignoreFocus);
|
||||||
|
} else {
|
||||||
|
requestKeyboard();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
// Use widget.controller.value in build()
|
||||||
|
// Trigger build and updateChildren
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onChangeTextEditingValue([bool ignoreCaret = false]) {
|
||||||
|
updateRemoteValueIfNeeded();
|
||||||
|
if (ignoreCaret) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showCaretOnScreen();
|
||||||
|
_cursorCont.startOrStopCursorTimerIfNeeded(
|
||||||
|
_hasFocus, widget.controller.selection);
|
||||||
|
if (hasConnection) {
|
||||||
|
_cursorCont
|
||||||
|
..stopCursorTimer(resetCharTicks: false)
|
||||||
|
..startCursorTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
SchedulerBinding.instance!.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_updateOrDisposeSelectionOverlayIfNeeded();
|
||||||
|
});
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
// Use widget.controller.value in build()
|
||||||
|
// Trigger build and updateChildren
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateOrDisposeSelectionOverlayIfNeeded() {
|
||||||
|
if (_selectionOverlay != null) {
|
||||||
|
if (_hasFocus) {
|
||||||
|
_selectionOverlay!.update(textEditingValue);
|
||||||
|
} else {
|
||||||
|
_selectionOverlay!.dispose();
|
||||||
|
_selectionOverlay = null;
|
||||||
|
}
|
||||||
|
} else if (_hasFocus) {
|
||||||
|
_selectionOverlay?.hide();
|
||||||
|
_selectionOverlay = null;
|
||||||
|
|
||||||
|
_selectionOverlay = EditorTextSelectionOverlay(
|
||||||
|
textEditingValue,
|
||||||
|
false,
|
||||||
|
context,
|
||||||
|
widget,
|
||||||
|
_toolbarLayerLink,
|
||||||
|
_startHandleLayerLink,
|
||||||
|
_endHandleLayerLink,
|
||||||
|
getRenderEditor(),
|
||||||
|
widget.selectionCtrls,
|
||||||
|
this,
|
||||||
|
DragStartBehavior.start,
|
||||||
|
null,
|
||||||
|
_clipboardStatus,
|
||||||
|
);
|
||||||
|
_selectionOverlay!.handlesVisible = _shouldShowSelectionHandles();
|
||||||
|
_selectionOverlay!.showHandles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFocusChanged() {
|
||||||
|
openOrCloseConnection();
|
||||||
|
_cursorCont.startOrStopCursorTimerIfNeeded(
|
||||||
|
_hasFocus, widget.controller.selection);
|
||||||
|
_updateOrDisposeSelectionOverlayIfNeeded();
|
||||||
|
if (_hasFocus) {
|
||||||
|
WidgetsBinding.instance!.addObserver(this);
|
||||||
|
_showCaretOnScreen();
|
||||||
|
} else {
|
||||||
|
WidgetsBinding.instance!.removeObserver(this);
|
||||||
|
}
|
||||||
|
updateKeepAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onChangedClipboardStatus() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
// Inform the widget that the value of clipboardStatus has changed.
|
||||||
|
// Trigger build and updateChildren
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _showCaretOnScreenScheduled = false;
|
||||||
|
|
||||||
|
void _showCaretOnScreen() {
|
||||||
|
if (!widget.showCursor || _showCaretOnScreenScheduled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_showCaretOnScreenScheduled = true;
|
||||||
|
SchedulerBinding.instance!.addPostFrameCallback((_) {
|
||||||
|
if (widget.scrollable || _scrollController.hasClients) {
|
||||||
|
_showCaretOnScreenScheduled = false;
|
||||||
|
|
||||||
|
final renderEditor = getRenderEditor();
|
||||||
|
if (renderEditor == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final viewport = RenderAbstractViewport.of(renderEditor);
|
||||||
|
final editorOffset =
|
||||||
|
renderEditor.localToGlobal(const Offset(0, 0), ancestor: viewport);
|
||||||
|
final offsetInViewport = _scrollController.offset + editorOffset.dy;
|
||||||
|
|
||||||
|
final offset = renderEditor.getOffsetToRevealCursor(
|
||||||
|
_scrollController.position.viewportDimension,
|
||||||
|
_scrollController.offset,
|
||||||
|
offsetInViewport,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (offset != null) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
math.min(offset, _scrollController.position.maxScrollExtent),
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderEditor? getRenderEditor() {
|
||||||
|
return _editorKey.currentContext?.findRenderObject() as RenderEditor?;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue getTextEditingValue() {
|
||||||
|
return widget.controller.plainTextEditingValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void requestKeyboard() {
|
||||||
|
if (_hasFocus) {
|
||||||
|
openConnectionIfNeeded();
|
||||||
|
_showCaretOnScreen();
|
||||||
|
} else {
|
||||||
|
widget.focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setTextEditingValue(TextEditingValue value) {
|
||||||
|
if (value.text == textEditingValue.text) {
|
||||||
|
widget.controller.updateSelection(value.selection, ChangeSource.LOCAL);
|
||||||
|
} else {
|
||||||
|
_setEditingValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// set editing value from clipboard for mobile
|
||||||
|
Future<void> _setEditingValue(TextEditingValue value) async {
|
||||||
|
if (await _isItCut(value)) {
|
||||||
|
widget.controller.replaceText(
|
||||||
|
textEditingValue.selection.start,
|
||||||
|
textEditingValue.text.length - value.text.length,
|
||||||
|
'',
|
||||||
|
value.selection,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final value = textEditingValue;
|
||||||
|
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
if (data != null) {
|
||||||
|
final length =
|
||||||
|
textEditingValue.selection.end - textEditingValue.selection.start;
|
||||||
|
var str = data.text!;
|
||||||
|
final codes = data.text!.codeUnits;
|
||||||
|
// For clip from editor, it may contain image, a.k.a 65532.
|
||||||
|
// For clip from browser, image is directly ignore.
|
||||||
|
// Here we skip image when pasting.
|
||||||
|
if (codes.contains(65532)) {
|
||||||
|
final sb = StringBuffer();
|
||||||
|
for (var i = 0; i < str.length; i++) {
|
||||||
|
if (str.codeUnitAt(i) == 65532) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.write(str[i]);
|
||||||
|
}
|
||||||
|
str = sb.toString();
|
||||||
|
}
|
||||||
|
widget.controller.replaceText(
|
||||||
|
value.selection.start,
|
||||||
|
length,
|
||||||
|
str,
|
||||||
|
value.selection,
|
||||||
|
);
|
||||||
|
// move cursor to the end of pasted text selection
|
||||||
|
widget.controller.updateSelection(
|
||||||
|
TextSelection.collapsed(
|
||||||
|
offset: value.selection.start + data.text!.length),
|
||||||
|
ChangeSource.LOCAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _isItCut(TextEditingValue value) async {
|
||||||
|
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
if (data == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return textEditingValue.text.length - value.text.length ==
|
||||||
|
data.text!.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool showToolbar() {
|
||||||
|
// Web is using native dom elements to enable clipboard functionality of the
|
||||||
|
// toolbar: copy, paste, select, cut. It might also provide additional
|
||||||
|
// functionality depending on the browser (such as translate). Due to this
|
||||||
|
// we should not show a Flutter toolbar for the editable text elements.
|
||||||
|
if (kIsWeb) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_selectionOverlay == null || _selectionOverlay!.toolbar != null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectionOverlay!.update(textEditingValue);
|
||||||
|
_selectionOverlay!.showToolbar();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => widget.focusNode.hasFocus;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Editor extends MultiChildRenderObjectWidget {
|
||||||
|
_Editor({
|
||||||
|
required Key key,
|
||||||
|
required List<Widget> children,
|
||||||
|
required this.document,
|
||||||
|
required this.textDirection,
|
||||||
|
required this.hasFocus,
|
||||||
|
required this.selection,
|
||||||
|
required this.startHandleLayerLink,
|
||||||
|
required this.endHandleLayerLink,
|
||||||
|
required this.onSelectionChanged,
|
||||||
|
required this.scrollBottomInset,
|
||||||
|
this.padding = EdgeInsets.zero,
|
||||||
|
}) : super(key: key, children: children);
|
||||||
|
|
||||||
|
final Document document;
|
||||||
|
final TextDirection textDirection;
|
||||||
|
final bool hasFocus;
|
||||||
|
final TextSelection selection;
|
||||||
|
final LayerLink startHandleLayerLink;
|
||||||
|
final LayerLink endHandleLayerLink;
|
||||||
|
final TextSelectionChangedHandler onSelectionChanged;
|
||||||
|
final double scrollBottomInset;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderEditor createRenderObject(BuildContext context) {
|
||||||
|
return RenderEditor(
|
||||||
|
null,
|
||||||
|
textDirection,
|
||||||
|
scrollBottomInset,
|
||||||
|
padding,
|
||||||
|
document,
|
||||||
|
selection,
|
||||||
|
hasFocus,
|
||||||
|
onSelectionChanged,
|
||||||
|
startHandleLayerLink,
|
||||||
|
endHandleLayerLink,
|
||||||
|
const EdgeInsets.fromLTRB(4, 4, 4, 5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(
|
||||||
|
BuildContext context, covariant RenderEditor renderObject) {
|
||||||
|
renderObject
|
||||||
|
..document = document
|
||||||
|
..setContainer(document.root)
|
||||||
|
..textDirection = textDirection
|
||||||
|
..setHasFocus(hasFocus)
|
||||||
|
..setSelection(selection)
|
||||||
|
..setStartHandleLayerLink(startHandleLayerLink)
|
||||||
|
..setEndHandleLayerLink(endHandleLayerLink)
|
||||||
|
..onSelectionChanged = onSelectionChanged
|
||||||
|
..setScrollBottomInset(scrollBottomInset)
|
||||||
|
..setPadding(padding);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,367 @@
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:characters/characters.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../../models/documents/document.dart';
|
||||||
|
import '../../utils/diff_delta.dart';
|
||||||
|
import '../editor.dart';
|
||||||
|
import '../keyboard_listener.dart';
|
||||||
|
|
||||||
|
mixin RawEditorStateKeyboardMixin on EditorState {
|
||||||
|
// Holds the last cursor location the user selected in the case the user tries
|
||||||
|
// to select vertically past the end or beginning of the field. If they do,
|
||||||
|
// then we need to keep the old cursor location so that we can go back to it
|
||||||
|
// if they change their minds. Only used for moving selection up and down in a
|
||||||
|
// multiline text field when selecting using the keyboard.
|
||||||
|
int _cursorResetLocation = -1;
|
||||||
|
|
||||||
|
// Whether we should reset the location of the cursor in the case the user
|
||||||
|
// tries to select vertically past the end or beginning of the field. If they
|
||||||
|
// do, then we need to keep the old cursor location so that we can go back to
|
||||||
|
// it if they change their minds. Only used for resetting selection up and
|
||||||
|
// down in a multiline text field when selecting using the keyboard.
|
||||||
|
bool _wasSelectingVerticallyWithKeyboard = false;
|
||||||
|
|
||||||
|
void handleCursorMovement(
|
||||||
|
LogicalKeyboardKey key,
|
||||||
|
bool wordModifier,
|
||||||
|
bool lineModifier,
|
||||||
|
bool shift,
|
||||||
|
) {
|
||||||
|
if (wordModifier && lineModifier) {
|
||||||
|
// If both modifiers are down, nothing happens on any of the platforms.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final selection = widget.controller.selection;
|
||||||
|
|
||||||
|
var newSelection = widget.controller.selection;
|
||||||
|
|
||||||
|
final plainText = getTextEditingValue().text;
|
||||||
|
|
||||||
|
final rightKey = key == LogicalKeyboardKey.arrowRight,
|
||||||
|
leftKey = key == LogicalKeyboardKey.arrowLeft,
|
||||||
|
upKey = key == LogicalKeyboardKey.arrowUp,
|
||||||
|
downKey = key == LogicalKeyboardKey.arrowDown;
|
||||||
|
|
||||||
|
if ((rightKey || leftKey) && !(rightKey && leftKey)) {
|
||||||
|
newSelection = _jumpToBeginOrEndOfWord(newSelection, wordModifier,
|
||||||
|
leftKey, rightKey, plainText, lineModifier, shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downKey || upKey) {
|
||||||
|
newSelection = _handleMovingCursorVertically(
|
||||||
|
upKey, downKey, shift, selection, newSelection, plainText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shift) {
|
||||||
|
newSelection =
|
||||||
|
_placeCollapsedSelection(selection, newSelection, leftKey, rightKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.controller.updateSelection(newSelection, ChangeSource.LOCAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles shortcut functionality including cut, copy, paste and select all
|
||||||
|
// using control/command + (X, C, V, A).
|
||||||
|
// TODO: Add support for formatting shortcuts: Cmd+B (bold), Cmd+I (italic)
|
||||||
|
// set editing value from clipboard for web
|
||||||
|
Future<void> handleShortcut(InputShortcut? shortcut) async {
|
||||||
|
final selection = widget.controller.selection;
|
||||||
|
final plainText = getTextEditingValue().text;
|
||||||
|
if (shortcut == InputShortcut.COPY) {
|
||||||
|
if (!selection.isCollapsed) {
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(text: selection.textInside(plainText)));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shortcut == InputShortcut.UNDO) {
|
||||||
|
if (widget.controller.hasUndo) {
|
||||||
|
widget.controller.undo();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shortcut == InputShortcut.REDO) {
|
||||||
|
if (widget.controller.hasRedo) {
|
||||||
|
widget.controller.redo();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shortcut == InputShortcut.CUT && !widget.readOnly) {
|
||||||
|
if (!selection.isCollapsed) {
|
||||||
|
final data = selection.textInside(plainText);
|
||||||
|
await Clipboard.setData(ClipboardData(text: data));
|
||||||
|
|
||||||
|
widget.controller.replaceText(
|
||||||
|
selection.start,
|
||||||
|
data.length,
|
||||||
|
'',
|
||||||
|
TextSelection.collapsed(offset: selection.start),
|
||||||
|
);
|
||||||
|
|
||||||
|
setTextEditingValue(TextEditingValue(
|
||||||
|
text:
|
||||||
|
selection.textBefore(plainText) + selection.textAfter(plainText),
|
||||||
|
selection: TextSelection.collapsed(offset: selection.start),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shortcut == InputShortcut.PASTE && !widget.readOnly) {
|
||||||
|
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
if (data != null) {
|
||||||
|
widget.controller.replaceText(
|
||||||
|
selection.start,
|
||||||
|
selection.end - selection.start,
|
||||||
|
data.text,
|
||||||
|
TextSelection.collapsed(offset: selection.start + data.text!.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shortcut == InputShortcut.SELECT_ALL &&
|
||||||
|
widget.enableInteractiveSelection) {
|
||||||
|
widget.controller.updateSelection(
|
||||||
|
selection.copyWith(
|
||||||
|
baseOffset: 0,
|
||||||
|
extentOffset: getTextEditingValue().text.length,
|
||||||
|
),
|
||||||
|
ChangeSource.REMOTE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleDelete(bool forward) {
|
||||||
|
final selection = widget.controller.selection;
|
||||||
|
final plainText = getTextEditingValue().text;
|
||||||
|
var cursorPosition = selection.start;
|
||||||
|
var textBefore = selection.textBefore(plainText);
|
||||||
|
var textAfter = selection.textAfter(plainText);
|
||||||
|
if (selection.isCollapsed) {
|
||||||
|
if (!forward && textBefore.isNotEmpty) {
|
||||||
|
final characterBoundary =
|
||||||
|
_previousCharacter(textBefore.length, textBefore, true);
|
||||||
|
textBefore = textBefore.substring(0, characterBoundary);
|
||||||
|
cursorPosition = characterBoundary;
|
||||||
|
}
|
||||||
|
if (forward && textAfter.isNotEmpty && textAfter != '\n') {
|
||||||
|
final deleteCount = _nextCharacter(0, textAfter, true);
|
||||||
|
textAfter = textAfter.substring(deleteCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final newSelection = TextSelection.collapsed(offset: cursorPosition);
|
||||||
|
final newText = textBefore + textAfter;
|
||||||
|
final size = plainText.length - newText.length;
|
||||||
|
widget.controller.replaceText(
|
||||||
|
cursorPosition,
|
||||||
|
size,
|
||||||
|
'',
|
||||||
|
newSelection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextSelection _jumpToBeginOrEndOfWord(
|
||||||
|
TextSelection newSelection,
|
||||||
|
bool wordModifier,
|
||||||
|
bool leftKey,
|
||||||
|
bool rightKey,
|
||||||
|
String plainText,
|
||||||
|
bool lineModifier,
|
||||||
|
bool shift) {
|
||||||
|
if (wordModifier) {
|
||||||
|
if (leftKey) {
|
||||||
|
final textSelection = getRenderEditor()!.selectWordAtPosition(
|
||||||
|
TextPosition(
|
||||||
|
offset: _previousCharacter(
|
||||||
|
newSelection.extentOffset, plainText, false)));
|
||||||
|
return newSelection.copyWith(extentOffset: textSelection.baseOffset);
|
||||||
|
}
|
||||||
|
final textSelection = getRenderEditor()!.selectWordAtPosition(
|
||||||
|
TextPosition(
|
||||||
|
offset:
|
||||||
|
_nextCharacter(newSelection.extentOffset, plainText, false)));
|
||||||
|
return newSelection.copyWith(extentOffset: textSelection.extentOffset);
|
||||||
|
} else if (lineModifier) {
|
||||||
|
if (leftKey) {
|
||||||
|
final textSelection = getRenderEditor()!.selectLineAtPosition(
|
||||||
|
TextPosition(
|
||||||
|
offset: _previousCharacter(
|
||||||
|
newSelection.extentOffset, plainText, false)));
|
||||||
|
return newSelection.copyWith(extentOffset: textSelection.baseOffset);
|
||||||
|
}
|
||||||
|
final startPoint = newSelection.extentOffset;
|
||||||
|
if (startPoint < plainText.length) {
|
||||||
|
final textSelection = getRenderEditor()!
|
||||||
|
.selectLineAtPosition(TextPosition(offset: startPoint));
|
||||||
|
return newSelection.copyWith(extentOffset: textSelection.extentOffset);
|
||||||
|
}
|
||||||
|
return newSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightKey && newSelection.extentOffset < plainText.length) {
|
||||||
|
final nextExtent =
|
||||||
|
_nextCharacter(newSelection.extentOffset, plainText, true);
|
||||||
|
final distance = nextExtent - newSelection.extentOffset;
|
||||||
|
newSelection = newSelection.copyWith(extentOffset: nextExtent);
|
||||||
|
if (shift) {
|
||||||
|
_cursorResetLocation += distance;
|
||||||
|
}
|
||||||
|
return newSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftKey && newSelection.extentOffset > 0) {
|
||||||
|
final previousExtent =
|
||||||
|
_previousCharacter(newSelection.extentOffset, plainText, true);
|
||||||
|
final distance = newSelection.extentOffset - previousExtent;
|
||||||
|
newSelection = newSelection.copyWith(extentOffset: previousExtent);
|
||||||
|
if (shift) {
|
||||||
|
_cursorResetLocation -= distance;
|
||||||
|
}
|
||||||
|
return newSelection;
|
||||||
|
}
|
||||||
|
return newSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the index into the string of the next character boundary after the
|
||||||
|
/// given index.
|
||||||
|
///
|
||||||
|
/// The character boundary is determined by the characters package, so
|
||||||
|
/// surrogate pairs and extended grapheme clusters are considered.
|
||||||
|
///
|
||||||
|
/// The index must be between 0 and string.length, inclusive. If given
|
||||||
|
/// string.length, string.length is returned.
|
||||||
|
///
|
||||||
|
/// Setting includeWhitespace to false will only return the index of non-space
|
||||||
|
/// characters.
|
||||||
|
int _nextCharacter(int index, String string, bool includeWhitespace) {
|
||||||
|
assert(index >= 0 && index <= string.length);
|
||||||
|
if (index == string.length) {
|
||||||
|
return string.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = 0;
|
||||||
|
final remain = string.characters.skipWhile((currentString) {
|
||||||
|
if (count <= index) {
|
||||||
|
count += currentString.length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (includeWhitespace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return WHITE_SPACE.contains(currentString.codeUnitAt(0));
|
||||||
|
});
|
||||||
|
return string.length - remain.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the index into the string of the previous character boundary
|
||||||
|
/// before the given index.
|
||||||
|
///
|
||||||
|
/// The character boundary is determined by the characters package, so
|
||||||
|
/// surrogate pairs and extended grapheme clusters are considered.
|
||||||
|
///
|
||||||
|
/// The index must be between 0 and string.length, inclusive. If index is 0,
|
||||||
|
/// 0 will be returned.
|
||||||
|
///
|
||||||
|
/// Setting includeWhitespace to false will only return the index of non-space
|
||||||
|
/// characters.
|
||||||
|
int _previousCharacter(int index, String string, includeWhitespace) {
|
||||||
|
assert(index >= 0 && index <= string.length);
|
||||||
|
if (index == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = 0;
|
||||||
|
int? lastNonWhitespace;
|
||||||
|
for (final currentString in string.characters) {
|
||||||
|
if (!includeWhitespace &&
|
||||||
|
!WHITE_SPACE.contains(
|
||||||
|
currentString.characters.first.toString().codeUnitAt(0))) {
|
||||||
|
lastNonWhitespace = count;
|
||||||
|
}
|
||||||
|
if (count + currentString.length >= index) {
|
||||||
|
return includeWhitespace ? count : lastNonWhitespace ?? 0;
|
||||||
|
}
|
||||||
|
count += currentString.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextSelection _handleMovingCursorVertically(
|
||||||
|
bool upKey,
|
||||||
|
bool downKey,
|
||||||
|
bool shift,
|
||||||
|
TextSelection selection,
|
||||||
|
TextSelection newSelection,
|
||||||
|
String plainText) {
|
||||||
|
final originPosition = TextPosition(
|
||||||
|
offset: upKey ? selection.baseOffset : selection.extentOffset);
|
||||||
|
|
||||||
|
final child = getRenderEditor()!.childAtPosition(originPosition);
|
||||||
|
final localPosition = TextPosition(
|
||||||
|
offset: originPosition.offset - child.getContainer().documentOffset);
|
||||||
|
|
||||||
|
var position = upKey
|
||||||
|
? child.getPositionAbove(localPosition)
|
||||||
|
: child.getPositionBelow(localPosition);
|
||||||
|
|
||||||
|
if (position == null) {
|
||||||
|
final sibling = upKey
|
||||||
|
? getRenderEditor()!.childBefore(child)
|
||||||
|
: getRenderEditor()!.childAfter(child);
|
||||||
|
if (sibling == null) {
|
||||||
|
position = TextPosition(offset: upKey ? 0 : plainText.length - 1);
|
||||||
|
} else {
|
||||||
|
final finalOffset = Offset(
|
||||||
|
child.getOffsetForCaret(localPosition).dx,
|
||||||
|
sibling
|
||||||
|
.getOffsetForCaret(TextPosition(
|
||||||
|
offset: upKey ? sibling.getContainer().length - 1 : 0))
|
||||||
|
.dy);
|
||||||
|
final siblingPosition = sibling.getPositionForOffset(finalOffset);
|
||||||
|
position = TextPosition(
|
||||||
|
offset:
|
||||||
|
sibling.getContainer().documentOffset + siblingPosition.offset);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
position = TextPosition(
|
||||||
|
offset: child.getContainer().documentOffset + position.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.offset == newSelection.extentOffset) {
|
||||||
|
if (downKey) {
|
||||||
|
newSelection = newSelection.copyWith(extentOffset: plainText.length);
|
||||||
|
} else if (upKey) {
|
||||||
|
newSelection = newSelection.copyWith(extentOffset: 0);
|
||||||
|
}
|
||||||
|
_wasSelectingVerticallyWithKeyboard = shift;
|
||||||
|
return newSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_wasSelectingVerticallyWithKeyboard && shift) {
|
||||||
|
newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation);
|
||||||
|
_wasSelectingVerticallyWithKeyboard = false;
|
||||||
|
return newSelection;
|
||||||
|
}
|
||||||
|
newSelection = newSelection.copyWith(extentOffset: position.offset);
|
||||||
|
_cursorResetLocation = newSelection.extentOffset;
|
||||||
|
return newSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextSelection _placeCollapsedSelection(TextSelection selection,
|
||||||
|
TextSelection newSelection, bool leftKey, bool rightKey) {
|
||||||
|
var newOffset = newSelection.extentOffset;
|
||||||
|
if (!selection.isCollapsed) {
|
||||||
|
if (leftKey) {
|
||||||
|
newOffset = newSelection.baseOffset < newSelection.extentOffset
|
||||||
|
? newSelection.baseOffset
|
||||||
|
: newSelection.extentOffset;
|
||||||
|
} else if (rightKey) {
|
||||||
|
newOffset = newSelection.baseOffset > newSelection.extentOffset
|
||||||
|
? newSelection.baseOffset
|
||||||
|
: newSelection.extentOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TextSelection.fromPosition(TextPosition(offset: newOffset));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../editor.dart';
|
||||||
|
|
||||||
|
mixin RawEditorStateSelectionDelegateMixin on EditorState
|
||||||
|
implements TextSelectionDelegate {
|
||||||
|
@override
|
||||||
|
TextEditingValue get textEditingValue {
|
||||||
|
return getTextEditingValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set textEditingValue(TextEditingValue value) {
|
||||||
|
setTextEditingValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void bringIntoView(TextPosition position) {
|
||||||
|
final localRect = getRenderEditor()!.getLocalRectForCaret(position);
|
||||||
|
final targetOffset = _getOffsetToRevealCaret(localRect, position);
|
||||||
|
|
||||||
|
scrollController.jumpTo(targetOffset.offset);
|
||||||
|
getRenderEditor()!.showOnScreen(rect: targetOffset.rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void copySelection(SelectionChangedCause cause) {
|
||||||
|
// TODO: implement copySelection
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void cutSelection(SelectionChangedCause cause) {
|
||||||
|
// TODO: implement cutSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> pasteText(SelectionChangedCause cause) {
|
||||||
|
// TODO: implement pasteText
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void selectAll(SelectionChangedCause cause) {
|
||||||
|
// TODO: implement selectAll
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finds the closest scroll offset to the current scroll offset that fully
|
||||||
|
// reveals the given caret rect. If the given rect's main axis extent is too
|
||||||
|
// large to be fully revealed in `renderEditable`, it will be centered along
|
||||||
|
// the main axis.
|
||||||
|
//
|
||||||
|
// If this is a multiline EditableText (which means the Editable can only
|
||||||
|
// scroll vertically), the given rect's height will first be extended to match
|
||||||
|
// `renderEditable.preferredLineHeight`, before the target scroll offset is
|
||||||
|
// calculated.
|
||||||
|
RevealedOffset _getOffsetToRevealCaret(Rect rect, TextPosition position) {
|
||||||
|
if (!scrollController.position.allowImplicitScrolling) {
|
||||||
|
return RevealedOffset(offset: scrollController.offset, rect: rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
final editableSize = getRenderEditor()!.size;
|
||||||
|
final double additionalOffset;
|
||||||
|
final Offset unitOffset;
|
||||||
|
|
||||||
|
// The caret is vertically centered within the line. Expand the caret's
|
||||||
|
// height so that it spans the line because we're going to ensure that the
|
||||||
|
// entire expanded caret is scrolled into view.
|
||||||
|
final expandedRect = Rect.fromCenter(
|
||||||
|
center: rect.center,
|
||||||
|
width: rect.width,
|
||||||
|
height:
|
||||||
|
max(rect.height, getRenderEditor()!.preferredLineHeight(position)),
|
||||||
|
);
|
||||||
|
|
||||||
|
additionalOffset = expandedRect.height >= editableSize.height
|
||||||
|
? editableSize.height / 2 - expandedRect.center.dy
|
||||||
|
: 0.0
|
||||||
|
.clamp(expandedRect.bottom - editableSize.height, expandedRect.top);
|
||||||
|
unitOffset = const Offset(0, 1);
|
||||||
|
|
||||||
|
// No overscrolling when encountering tall fonts/scripts that extend past
|
||||||
|
// the ascent.
|
||||||
|
final targetOffset = (additionalOffset + scrollController.offset).clamp(
|
||||||
|
scrollController.position.minScrollExtent,
|
||||||
|
scrollController.position.maxScrollExtent,
|
||||||
|
);
|
||||||
|
|
||||||
|
final offsetDelta = scrollController.offset - targetOffset;
|
||||||
|
return RevealedOffset(
|
||||||
|
rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void hideToolbar([bool hideHandles = true]) {
|
||||||
|
if (getSelectionOverlay()?.toolbar != null) {
|
||||||
|
getSelectionOverlay()?.hideToolbar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void userUpdateTextEditingValue(
|
||||||
|
TextEditingValue value,
|
||||||
|
SelectionChangedCause cause,
|
||||||
|
) {
|
||||||
|
setTextEditingValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get copyEnabled => widget.toolbarOptions.copy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get selectAllEnabled => widget.toolbarOptions.selectAll;
|
||||||
|
}
|
|
@ -0,0 +1,204 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../../utils/diff_delta.dart';
|
||||||
|
import '../editor.dart';
|
||||||
|
|
||||||
|
mixin RawEditorStateTextInputClientMixin on EditorState
|
||||||
|
implements TextInputClient {
|
||||||
|
final List<TextEditingValue> _sentRemoteValues = [];
|
||||||
|
TextInputConnection? _textInputConnection;
|
||||||
|
TextEditingValue? _lastKnownRemoteTextEditingValue;
|
||||||
|
|
||||||
|
/// Whether to create an input connection with the platform for text editing
|
||||||
|
/// or not.
|
||||||
|
///
|
||||||
|
/// Read-only input fields do not need a connection with the platform since
|
||||||
|
/// there's no need for text editing capabilities (e.g. virtual keyboard).
|
||||||
|
///
|
||||||
|
/// On the web, we always need a connection because we want some browser
|
||||||
|
/// functionalities to continue to work on read-only input fields like:
|
||||||
|
///
|
||||||
|
/// - Relevant context menu.
|
||||||
|
/// - cmd/ctrl+c shortcut to copy.
|
||||||
|
/// - cmd/ctrl+a to select all.
|
||||||
|
/// - Changing the selection using a physical keyboard.
|
||||||
|
bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly;
|
||||||
|
|
||||||
|
/// Returns `true` if there is open input connection.
|
||||||
|
bool get hasConnection =>
|
||||||
|
_textInputConnection != null && _textInputConnection!.attached;
|
||||||
|
|
||||||
|
/// Opens or closes input connection based on the current state of
|
||||||
|
/// [focusNode] and [value].
|
||||||
|
void openOrCloseConnection() {
|
||||||
|
if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) {
|
||||||
|
openConnectionIfNeeded();
|
||||||
|
} else if (!widget.focusNode.hasFocus) {
|
||||||
|
closeConnectionIfNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void openConnectionIfNeeded() {
|
||||||
|
if (!shouldCreateInputConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasConnection) {
|
||||||
|
_lastKnownRemoteTextEditingValue = getTextEditingValue();
|
||||||
|
_textInputConnection = TextInput.attach(
|
||||||
|
this,
|
||||||
|
TextInputConfiguration(
|
||||||
|
inputType: TextInputType.multiline,
|
||||||
|
readOnly: widget.readOnly,
|
||||||
|
inputAction: TextInputAction.newline,
|
||||||
|
enableSuggestions: !widget.readOnly,
|
||||||
|
keyboardAppearance: widget.keyboardAppearance,
|
||||||
|
textCapitalization: widget.textCapitalization,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!);
|
||||||
|
// _sentRemoteValues.add(_lastKnownRemoteTextEditingValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
_textInputConnection!.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes input connection if it's currently open. Otherwise does nothing.
|
||||||
|
void closeConnectionIfNeeded() {
|
||||||
|
if (!hasConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_textInputConnection!.close();
|
||||||
|
_textInputConnection = null;
|
||||||
|
_lastKnownRemoteTextEditingValue = null;
|
||||||
|
_sentRemoteValues.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates remote value based on current state of [document] and
|
||||||
|
/// [selection].
|
||||||
|
///
|
||||||
|
/// This method may not actually send an update to native side if it thinks
|
||||||
|
/// remote value is up to date or identical.
|
||||||
|
void updateRemoteValueIfNeeded() {
|
||||||
|
if (!hasConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since we don't keep track of the composing range in value provided
|
||||||
|
// by the Controller we need to add it here manually before comparing
|
||||||
|
// with the last known remote value.
|
||||||
|
// It is important to prevent excessive remote updates as it can cause
|
||||||
|
// race conditions.
|
||||||
|
final actualValue = getTextEditingValue().copyWith(
|
||||||
|
composing: _lastKnownRemoteTextEditingValue!.composing,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (actualValue == _lastKnownRemoteTextEditingValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final shouldRemember =
|
||||||
|
getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text;
|
||||||
|
_lastKnownRemoteTextEditingValue = actualValue;
|
||||||
|
_textInputConnection!.setEditingState(
|
||||||
|
// Set composing to (-1, -1), otherwise an exception will be thrown if
|
||||||
|
// the values are different.
|
||||||
|
actualValue.copyWith(composing: const TextRange(start: -1, end: -1)),
|
||||||
|
);
|
||||||
|
if (shouldRemember) {
|
||||||
|
// Only keep track if text changed (selection changes are not relevant)
|
||||||
|
_sentRemoteValues.add(actualValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue? get currentTextEditingValue =>
|
||||||
|
_lastKnownRemoteTextEditingValue;
|
||||||
|
|
||||||
|
// autofill is not needed
|
||||||
|
@override
|
||||||
|
AutofillScope? get currentAutofillScope => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateEditingValue(TextEditingValue value) {
|
||||||
|
if (!shouldCreateInputConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_sentRemoteValues.contains(value)) {
|
||||||
|
/// There is a race condition in Flutter text input plugin where sending
|
||||||
|
/// updates to native side too often results in broken behavior.
|
||||||
|
/// TextInputConnection.setEditingValue is an async call to native side.
|
||||||
|
/// For each such call native side _always_ sends an update which triggers
|
||||||
|
/// this method (updateEditingValue) with the same value we've sent it.
|
||||||
|
/// If multiple calls to setEditingValue happen too fast and we only
|
||||||
|
/// track the last sent value then there is no way for us to filter out
|
||||||
|
/// automatic callbacks from native side.
|
||||||
|
/// Therefore we have to keep track of all values we send to the native
|
||||||
|
/// side and when we see this same value appear here we skip it.
|
||||||
|
/// This is fragile but it's probably the only available option.
|
||||||
|
_sentRemoteValues.remove(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_lastKnownRemoteTextEditingValue == value) {
|
||||||
|
// There is no difference between this value and the last known value.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if only composing range changed.
|
||||||
|
if (_lastKnownRemoteTextEditingValue!.text == value.text &&
|
||||||
|
_lastKnownRemoteTextEditingValue!.selection == value.selection) {
|
||||||
|
// This update only modifies composing range. Since we don't keep track
|
||||||
|
// of composing range we just need to update last known value here.
|
||||||
|
// This check fixes an issue on Android when it sends
|
||||||
|
// composing updates separately from regular changes for text and
|
||||||
|
// selection.
|
||||||
|
_lastKnownRemoteTextEditingValue = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!;
|
||||||
|
_lastKnownRemoteTextEditingValue = value;
|
||||||
|
final oldText = effectiveLastKnownValue.text;
|
||||||
|
final text = value.text;
|
||||||
|
final cursorPosition = value.selection.extentOffset;
|
||||||
|
final diff = getDiff(oldText, text, cursorPosition);
|
||||||
|
widget.controller.replaceText(
|
||||||
|
diff.start, diff.deleted.length, diff.inserted, value.selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void performAction(TextInputAction action) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void performPrivateCommand(String action, Map<String, dynamic> data) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateFloatingCursor(RawFloatingCursorPoint point) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void showAutocorrectionPromptRect(int start, int end) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void connectionClosed() {
|
||||||
|
if (!hasConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_textInputConnection!.connectionClosedReceived();
|
||||||
|
_textInputConnection = null;
|
||||||
|
_lastKnownRemoteTextEditingValue = null;
|
||||||
|
_sentRemoteValues.clear();
|
||||||
|
}
|
||||||
|
}
|
362
app_flowy/packages/editor/lib/src/widgets/simple_viewer.dart
Normal file
|
@ -0,0 +1,362 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io' as io;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:string_validator/string_validator.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
import '../models/documents/attribute.dart';
|
||||||
|
import '../models/documents/document.dart';
|
||||||
|
import '../models/documents/nodes/block.dart';
|
||||||
|
import '../models/documents/nodes/leaf.dart' as leaf;
|
||||||
|
import '../models/documents/nodes/line.dart';
|
||||||
|
import 'controller.dart';
|
||||||
|
import 'cursor.dart';
|
||||||
|
import 'default_styles.dart';
|
||||||
|
import 'delegate.dart';
|
||||||
|
import 'editor.dart';
|
||||||
|
import 'text_block.dart';
|
||||||
|
import 'text_line.dart';
|
||||||
|
import 'video_app.dart';
|
||||||
|
import 'youtube_video_app.dart';
|
||||||
|
|
||||||
|
class QuillSimpleViewer extends StatefulWidget {
|
||||||
|
const QuillSimpleViewer({
|
||||||
|
required this.controller,
|
||||||
|
required this.readOnly,
|
||||||
|
this.customStyles,
|
||||||
|
this.truncate = false,
|
||||||
|
this.truncateScale,
|
||||||
|
this.truncateAlignment,
|
||||||
|
this.truncateHeight,
|
||||||
|
this.truncateWidth,
|
||||||
|
this.scrollBottomInset = 0,
|
||||||
|
this.padding = EdgeInsets.zero,
|
||||||
|
this.embedBuilder,
|
||||||
|
Key? key,
|
||||||
|
}) : assert(truncate ||
|
||||||
|
((truncateScale == null) &&
|
||||||
|
(truncateAlignment == null) &&
|
||||||
|
(truncateHeight == null) &&
|
||||||
|
(truncateWidth == null))),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
final QuillController controller;
|
||||||
|
final DefaultStyles? customStyles;
|
||||||
|
final bool truncate;
|
||||||
|
final double? truncateScale;
|
||||||
|
final Alignment? truncateAlignment;
|
||||||
|
final double? truncateHeight;
|
||||||
|
final double? truncateWidth;
|
||||||
|
final double scrollBottomInset;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
final EmbedBuilder? embedBuilder;
|
||||||
|
final bool readOnly;
|
||||||
|
|
||||||
|
@override
|
||||||
|
_QuillSimpleViewerState createState() => _QuillSimpleViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuillSimpleViewerState extends State<QuillSimpleViewer>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late DefaultStyles _styles;
|
||||||
|
final LayerLink _toolbarLayerLink = LayerLink();
|
||||||
|
final LayerLink _startHandleLayerLink = LayerLink();
|
||||||
|
final LayerLink _endHandleLayerLink = LayerLink();
|
||||||
|
late CursorCont _cursorCont;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_cursorCont = CursorCont(
|
||||||
|
show: ValueNotifier<bool>(false),
|
||||||
|
style: const CursorStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
backgroundColor: Colors.grey,
|
||||||
|
width: 2,
|
||||||
|
radius: Radius.zero,
|
||||||
|
offset: Offset.zero,
|
||||||
|
),
|
||||||
|
tickerProvider: this,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final parentStyles = QuillStyles.getStyles(context, true);
|
||||||
|
final defaultStyles = DefaultStyles.getInstance(context);
|
||||||
|
_styles = (parentStyles != null)
|
||||||
|
? defaultStyles.merge(parentStyles)
|
||||||
|
: defaultStyles;
|
||||||
|
|
||||||
|
if (widget.customStyles != null) {
|
||||||
|
_styles = _styles.merge(widget.customStyles!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EmbedBuilder get embedBuilder => widget.embedBuilder ?? _defaultEmbedBuilder;
|
||||||
|
|
||||||
|
Widget _defaultEmbedBuilder(
|
||||||
|
BuildContext context, leaf.Embed node, bool readOnly) {
|
||||||
|
assert(!kIsWeb, 'Please provide EmbedBuilder for Web');
|
||||||
|
switch (node.value.type) {
|
||||||
|
case 'image':
|
||||||
|
final imageUrl = _standardizeImageUrl(node.value.data);
|
||||||
|
return imageUrl.startsWith('http')
|
||||||
|
? Image.network(imageUrl)
|
||||||
|
: isBase64(imageUrl)
|
||||||
|
? Image.memory(base64.decode(imageUrl))
|
||||||
|
: Image.file(io.File(imageUrl));
|
||||||
|
case 'video':
|
||||||
|
final videoUrl = node.value.data;
|
||||||
|
if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) {
|
||||||
|
return YoutubeVideoApp(
|
||||||
|
videoUrl: videoUrl, context: context, readOnly: readOnly);
|
||||||
|
}
|
||||||
|
return VideoApp(
|
||||||
|
videoUrl: videoUrl, context: context, readOnly: readOnly);
|
||||||
|
default:
|
||||||
|
throw UnimplementedError(
|
||||||
|
'Embeddable type "${node.value.type}" is not supported by default '
|
||||||
|
'embed builder of QuillEditor. You must pass your own builder '
|
||||||
|
'function to embedBuilder property of QuillEditor or QuillField '
|
||||||
|
'widgets.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _standardizeImageUrl(String url) {
|
||||||
|
if (url.contains('base64')) {
|
||||||
|
return url.split(',')[1];
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final _doc = widget.controller.document;
|
||||||
|
// if (_doc.isEmpty() &&
|
||||||
|
// !widget.focusNode.hasFocus &&
|
||||||
|
// widget.placeholder != null) {
|
||||||
|
// _doc = Document.fromJson(jsonDecode(
|
||||||
|
// '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]'));
|
||||||
|
// }
|
||||||
|
|
||||||
|
Widget child = CompositedTransformTarget(
|
||||||
|
link: _toolbarLayerLink,
|
||||||
|
child: Semantics(
|
||||||
|
child: _SimpleViewer(
|
||||||
|
document: _doc,
|
||||||
|
textDirection: _textDirection,
|
||||||
|
startHandleLayerLink: _startHandleLayerLink,
|
||||||
|
endHandleLayerLink: _endHandleLayerLink,
|
||||||
|
onSelectionChanged: _nullSelectionChanged,
|
||||||
|
scrollBottomInset: widget.scrollBottomInset,
|
||||||
|
padding: widget.padding,
|
||||||
|
children: _buildChildren(_doc, context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.truncate) {
|
||||||
|
if (widget.truncateScale != null) {
|
||||||
|
child = Container(
|
||||||
|
height: widget.truncateHeight,
|
||||||
|
child: Align(
|
||||||
|
heightFactor: widget.truncateScale,
|
||||||
|
widthFactor: widget.truncateScale,
|
||||||
|
alignment: widget.truncateAlignment ?? Alignment.topLeft,
|
||||||
|
child: Container(
|
||||||
|
width: widget.truncateWidth! / widget.truncateScale!,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: widget.truncateScale!,
|
||||||
|
alignment:
|
||||||
|
widget.truncateAlignment ?? Alignment.topLeft,
|
||||||
|
child: child)))));
|
||||||
|
} else {
|
||||||
|
child = Container(
|
||||||
|
height: widget.truncateHeight,
|
||||||
|
width: widget.truncateWidth,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(), child: child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return QuillStyles(data: _styles, child: child);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildChildren(Document doc, BuildContext context) {
|
||||||
|
final result = <Widget>[];
|
||||||
|
final indentLevelCounts = <int, int>{};
|
||||||
|
for (final node in doc.root.children) {
|
||||||
|
if (node is Line) {
|
||||||
|
final editableTextLine = _getEditableTextLineFromNode(node, context);
|
||||||
|
result.add(editableTextLine);
|
||||||
|
} else if (node is Block) {
|
||||||
|
final attrs = node.style.attributes;
|
||||||
|
final editableTextBlock = EditableTextBlock(
|
||||||
|
block: node,
|
||||||
|
textDirection: _textDirection,
|
||||||
|
scrollBottomInset: widget.scrollBottomInset,
|
||||||
|
verticalSpacing: _getVerticalSpacingForBlock(node, _styles),
|
||||||
|
textSelection: widget.controller.selection,
|
||||||
|
color: Colors.black,
|
||||||
|
styles: _styles,
|
||||||
|
enableInteractiveSelection: false,
|
||||||
|
hasFocus: false,
|
||||||
|
contentPadding: attrs.containsKey(Attribute.codeBlock.key)
|
||||||
|
? const EdgeInsets.all(16)
|
||||||
|
: null,
|
||||||
|
embedBuilder: embedBuilder,
|
||||||
|
cursorCont: _cursorCont,
|
||||||
|
indentLevelCounts: indentLevelCounts,
|
||||||
|
onCheckboxTap: _handleCheckboxTap,
|
||||||
|
readOnly: widget.readOnly);
|
||||||
|
result.add(editableTextBlock);
|
||||||
|
} else {
|
||||||
|
throw StateError('Unreachable.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the checkbox positioned at [offset] in document
|
||||||
|
/// by changing its attribute according to [value].
|
||||||
|
void _handleCheckboxTap(int offset, bool value) {
|
||||||
|
// readonly - do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
TextDirection get _textDirection {
|
||||||
|
final result = Directionality.of(context);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditableTextLine _getEditableTextLineFromNode(
|
||||||
|
Line node, BuildContext context) {
|
||||||
|
final textLine = TextLine(
|
||||||
|
line: node,
|
||||||
|
textDirection: _textDirection,
|
||||||
|
embedBuilder: embedBuilder,
|
||||||
|
styles: _styles,
|
||||||
|
readOnly: widget.readOnly,
|
||||||
|
);
|
||||||
|
final editableTextLine = EditableTextLine(
|
||||||
|
node,
|
||||||
|
null,
|
||||||
|
textLine,
|
||||||
|
0,
|
||||||
|
_getVerticalSpacingForLine(node, _styles),
|
||||||
|
_textDirection,
|
||||||
|
widget.controller.selection,
|
||||||
|
Colors.black,
|
||||||
|
//widget.selectionColor,
|
||||||
|
false,
|
||||||
|
//enableInteractiveSelection,
|
||||||
|
false,
|
||||||
|
//_hasFocus,
|
||||||
|
MediaQuery.of(context).devicePixelRatio,
|
||||||
|
_cursorCont);
|
||||||
|
return editableTextLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2<double, double> _getVerticalSpacingForLine(
|
||||||
|
Line line, DefaultStyles? defaultStyles) {
|
||||||
|
final attrs = line.style.attributes;
|
||||||
|
if (attrs.containsKey(Attribute.header.key)) {
|
||||||
|
final int? level = attrs[Attribute.header.key]!.value;
|
||||||
|
switch (level) {
|
||||||
|
case 1:
|
||||||
|
return defaultStyles!.h1!.verticalSpacing;
|
||||||
|
case 2:
|
||||||
|
return defaultStyles!.h2!.verticalSpacing;
|
||||||
|
case 3:
|
||||||
|
return defaultStyles!.h3!.verticalSpacing;
|
||||||
|
default:
|
||||||
|
throw 'Invalid level $level';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultStyles!.paragraph!.verticalSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2<double, double> _getVerticalSpacingForBlock(
|
||||||
|
Block node, DefaultStyles? defaultStyles) {
|
||||||
|
final attrs = node.style.attributes;
|
||||||
|
if (attrs.containsKey(Attribute.blockQuote.key)) {
|
||||||
|
return defaultStyles!.quote!.verticalSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.codeBlock.key)) {
|
||||||
|
return defaultStyles!.code!.verticalSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.indent.key)) {
|
||||||
|
return defaultStyles!.indent!.verticalSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.list.key)) {
|
||||||
|
return defaultStyles!.lists!.verticalSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.align.key)) {
|
||||||
|
return defaultStyles!.align!.verticalSpacing;
|
||||||
|
}
|
||||||
|
return const Tuple2(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _nullSelectionChanged(
|
||||||
|
TextSelection selection, SelectionChangedCause cause) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SimpleViewer extends MultiChildRenderObjectWidget {
|
||||||
|
_SimpleViewer({
|
||||||
|
required List<Widget> children,
|
||||||
|
required this.document,
|
||||||
|
required this.textDirection,
|
||||||
|
required this.startHandleLayerLink,
|
||||||
|
required this.endHandleLayerLink,
|
||||||
|
required this.onSelectionChanged,
|
||||||
|
required this.scrollBottomInset,
|
||||||
|
this.padding = EdgeInsets.zero,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key, children: children);
|
||||||
|
|
||||||
|
final Document document;
|
||||||
|
final TextDirection textDirection;
|
||||||
|
final LayerLink startHandleLayerLink;
|
||||||
|
final LayerLink endHandleLayerLink;
|
||||||
|
final TextSelectionChangedHandler onSelectionChanged;
|
||||||
|
final double scrollBottomInset;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderEditor createRenderObject(BuildContext context) {
|
||||||
|
return RenderEditor(
|
||||||
|
null,
|
||||||
|
textDirection,
|
||||||
|
scrollBottomInset,
|
||||||
|
padding,
|
||||||
|
document,
|
||||||
|
const TextSelection(baseOffset: 0, extentOffset: 0),
|
||||||
|
false,
|
||||||
|
// hasFocus,
|
||||||
|
onSelectionChanged,
|
||||||
|
startHandleLayerLink,
|
||||||
|
endHandleLayerLink,
|
||||||
|
const EdgeInsets.fromLTRB(4, 4, 4, 5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(
|
||||||
|
BuildContext context, covariant RenderEditor renderObject) {
|
||||||
|
renderObject
|
||||||
|
..document = document
|
||||||
|
..setContainer(document.root)
|
||||||
|
..textDirection = textDirection
|
||||||
|
..setStartHandleLayerLink(startHandleLayerLink)
|
||||||
|
..setEndHandleLayerLink(endHandleLayerLink)
|
||||||
|
..onSelectionChanged = onSelectionChanged
|
||||||
|
..setScrollBottomInset(scrollBottomInset)
|
||||||
|
..setPadding(padding);
|
||||||
|
}
|
||||||
|
}
|
772
app_flowy/packages/editor/lib/src/widgets/text_block.dart
Normal file
|
@ -0,0 +1,772 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
import '../models/documents/attribute.dart';
|
||||||
|
import '../models/documents/nodes/block.dart';
|
||||||
|
import '../models/documents/nodes/line.dart';
|
||||||
|
import 'box.dart';
|
||||||
|
import 'cursor.dart';
|
||||||
|
import 'default_styles.dart';
|
||||||
|
import 'delegate.dart';
|
||||||
|
import 'editor.dart';
|
||||||
|
import 'text_line.dart';
|
||||||
|
import 'text_selection.dart';
|
||||||
|
|
||||||
|
const List<int> arabianRomanNumbers = [
|
||||||
|
1000,
|
||||||
|
900,
|
||||||
|
500,
|
||||||
|
400,
|
||||||
|
100,
|
||||||
|
90,
|
||||||
|
50,
|
||||||
|
40,
|
||||||
|
10,
|
||||||
|
9,
|
||||||
|
5,
|
||||||
|
4,
|
||||||
|
1
|
||||||
|
];
|
||||||
|
|
||||||
|
const List<String> romanNumbers = [
|
||||||
|
'M',
|
||||||
|
'CM',
|
||||||
|
'D',
|
||||||
|
'CD',
|
||||||
|
'C',
|
||||||
|
'XC',
|
||||||
|
'L',
|
||||||
|
'XL',
|
||||||
|
'X',
|
||||||
|
'IX',
|
||||||
|
'V',
|
||||||
|
'IV',
|
||||||
|
'I'
|
||||||
|
];
|
||||||
|
|
||||||
|
class EditableTextBlock extends StatelessWidget {
|
||||||
|
const EditableTextBlock(
|
||||||
|
{required this.block,
|
||||||
|
required this.textDirection,
|
||||||
|
required this.scrollBottomInset,
|
||||||
|
required this.verticalSpacing,
|
||||||
|
required this.textSelection,
|
||||||
|
required this.color,
|
||||||
|
required this.styles,
|
||||||
|
required this.enableInteractiveSelection,
|
||||||
|
required this.hasFocus,
|
||||||
|
required this.contentPadding,
|
||||||
|
required this.embedBuilder,
|
||||||
|
required this.cursorCont,
|
||||||
|
required this.indentLevelCounts,
|
||||||
|
required this.onCheckboxTap,
|
||||||
|
required this.readOnly,
|
||||||
|
this.customStyleBuilder,
|
||||||
|
Key? key});
|
||||||
|
|
||||||
|
final Block block;
|
||||||
|
final TextDirection textDirection;
|
||||||
|
final double scrollBottomInset;
|
||||||
|
final Tuple2 verticalSpacing;
|
||||||
|
final TextSelection textSelection;
|
||||||
|
final Color color;
|
||||||
|
final DefaultStyles? styles;
|
||||||
|
final bool enableInteractiveSelection;
|
||||||
|
final bool hasFocus;
|
||||||
|
final EdgeInsets? contentPadding;
|
||||||
|
final EmbedBuilder embedBuilder;
|
||||||
|
final CustomStyleBuilder? customStyleBuilder;
|
||||||
|
final CursorCont cursorCont;
|
||||||
|
final Map<int, int> indentLevelCounts;
|
||||||
|
final Function(int, bool) onCheckboxTap;
|
||||||
|
final bool readOnly;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
assert(debugCheckHasMediaQuery(context));
|
||||||
|
|
||||||
|
final defaultStyles = QuillStyles.getStyles(context, false);
|
||||||
|
return _EditableBlock(
|
||||||
|
block,
|
||||||
|
textDirection,
|
||||||
|
verticalSpacing as Tuple2<double, double>,
|
||||||
|
scrollBottomInset,
|
||||||
|
_getDecorationForBlock(block, defaultStyles) ?? const BoxDecoration(),
|
||||||
|
contentPadding,
|
||||||
|
_buildChildren(context, indentLevelCounts));
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxDecoration? _getDecorationForBlock(
|
||||||
|
Block node, DefaultStyles? defaultStyles) {
|
||||||
|
final attrs = block.style.attributes;
|
||||||
|
if (attrs.containsKey(Attribute.blockQuote.key)) {
|
||||||
|
return defaultStyles!.quote!.decoration;
|
||||||
|
}
|
||||||
|
if (attrs.containsKey(Attribute.codeBlock.key)) {
|
||||||
|
return defaultStyles!.code!.decoration;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildChildren(
|
||||||
|
BuildContext context, Map<int, int> indentLevelCounts) {
|
||||||
|
final defaultStyles = QuillStyles.getStyles(context, false);
|
||||||
|
final count = block.children.length;
|
||||||
|
final children = <Widget>[];
|
||||||
|
var index = 0;
|
||||||
|
for (final line in Iterable.castFrom<dynamic, Line>(block.children)) {
|
||||||
|
index++;
|
||||||
|
final editableTextLine = EditableTextLine(
|
||||||
|
line,
|
||||||
|
_buildLeading(context, line, index, indentLevelCounts, count),
|
||||||
|
TextLine(
|
||||||
|
line: line,
|
||||||
|
textDirection: textDirection,
|
||||||
|
embedBuilder: embedBuilder,
|
||||||
|
customStyleBuilder: customStyleBuilder,
|
||||||
|
styles: styles!,
|
||||||
|
readOnly: readOnly,
|
||||||
|
),
|
||||||
|
_getIndentWidth(),
|
||||||
|
_getSpacingForLine(line, index, count, defaultStyles),
|
||||||
|
textDirection,
|
||||||
|
textSelection,
|
||||||
|
color,
|
||||||
|
enableInteractiveSelection,
|
||||||
|
hasFocus,
|
||||||
|
MediaQuery.of(context).devicePixelRatio,
|
||||||
|
cursorCont);
|
||||||
|
children.add(editableTextLine);
|
||||||
|
}
|
||||||
|
return children.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _buildLeading(BuildContext context, Line line, int index,
|
||||||
|
Map<int, int> indentLevelCounts, int count) {
|
||||||
|
final defaultStyles = QuillStyles.getStyles(context, false);
|
||||||
|
final attrs = line.style.attributes;
|
||||||
|
if (attrs[Attribute.list.key] == Attribute.ol) {
|
||||||
|
return _NumberPoint(
|
||||||
|
index: index,
|
||||||
|
indentLevelCounts: indentLevelCounts,
|
||||||
|
count: count,
|
||||||
|
style: defaultStyles!.leading!.style,
|
||||||
|
attrs: attrs,
|
||||||
|
width: 32,
|
||||||
|
padding: 8,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs[Attribute.list.key] == Attribute.ul) {
|
||||||
|
return _BulletPoint(
|
||||||
|
style:
|
||||||
|
defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
width: 32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs[Attribute.list.key] == Attribute.checked) {
|
||||||
|
return _Checkbox(
|
||||||
|
key: UniqueKey(),
|
||||||
|
style: defaultStyles!.leading!.style,
|
||||||
|
width: 32,
|
||||||
|
isChecked: true,
|
||||||
|
offset: block.offset + line.offset,
|
||||||
|
onTap: onCheckboxTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs[Attribute.list.key] == Attribute.unchecked) {
|
||||||
|
return _Checkbox(
|
||||||
|
key: UniqueKey(),
|
||||||
|
style: defaultStyles!.leading!.style,
|
||||||
|
width: 32,
|
||||||
|
offset: block.offset + line.offset,
|
||||||
|
onTap: onCheckboxTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.containsKey(Attribute.codeBlock.key)) {
|
||||||
|
return _NumberPoint(
|
||||||
|
index: index,
|
||||||
|
indentLevelCounts: indentLevelCounts,
|
||||||
|
count: count,
|
||||||
|
style: defaultStyles!.code!.style
|
||||||
|
.copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)),
|
||||||
|
width: 32,
|
||||||
|
attrs: attrs,
|
||||||
|
padding: 16,
|
||||||
|
withDot: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _getIndentWidth() {
|
||||||
|
final attrs = block.style.attributes;
|
||||||
|
|
||||||
|
final indent = attrs[Attribute.indent.key];
|
||||||
|
var extraIndent = 0.0;
|
||||||
|
if (indent != null && indent.value != null) {
|
||||||
|
extraIndent = 16.0 * indent.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.containsKey(Attribute.blockQuote.key)) {
|
||||||
|
return 16.0 + extraIndent;
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseIndent = 0.0;
|
||||||
|
|
||||||
|
if (attrs.containsKey(Attribute.list.key) ||
|
||||||
|
attrs.containsKey(Attribute.codeBlock.key)) {
|
||||||
|
baseIndent = 32.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseIndent + extraIndent;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tuple2 _getSpacingForLine(
|
||||||
|
Line node, int index, int count, DefaultStyles? defaultStyles) {
|
||||||
|
var top = 0.0, bottom = 0.0;
|
||||||
|
|
||||||
|
final attrs = block.style.attributes;
|
||||||
|
if (attrs.containsKey(Attribute.header.key)) {
|
||||||
|
final level = attrs[Attribute.header.key]!.value;
|
||||||
|
switch (level) {
|
||||||
|
case 1:
|
||||||
|
top = defaultStyles!.h1!.verticalSpacing.item1;
|
||||||
|
bottom = defaultStyles.h1!.verticalSpacing.item2;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
top = defaultStyles!.h2!.verticalSpacing.item1;
|
||||||
|
bottom = defaultStyles.h2!.verticalSpacing.item2;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
top = defaultStyles!.h3!.verticalSpacing.item1;
|
||||||
|
bottom = defaultStyles.h3!.verticalSpacing.item2;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw 'Invalid level $level';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
late Tuple2 lineSpacing;
|
||||||
|
if (attrs.containsKey(Attribute.blockQuote.key)) {
|
||||||
|
lineSpacing = defaultStyles!.quote!.lineSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.indent.key)) {
|
||||||
|
lineSpacing = defaultStyles!.indent!.lineSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.list.key)) {
|
||||||
|
lineSpacing = defaultStyles!.lists!.lineSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.codeBlock.key)) {
|
||||||
|
lineSpacing = defaultStyles!.code!.lineSpacing;
|
||||||
|
} else if (attrs.containsKey(Attribute.align.key)) {
|
||||||
|
lineSpacing = defaultStyles!.align!.lineSpacing;
|
||||||
|
}
|
||||||
|
top = lineSpacing.item1;
|
||||||
|
bottom = lineSpacing.item2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == 1) {
|
||||||
|
top = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == count) {
|
||||||
|
bottom = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tuple2(top, bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RenderEditableTextBlock extends RenderEditableContainerBox
|
||||||
|
implements RenderEditableBox {
|
||||||
|
RenderEditableTextBlock({
|
||||||
|
required Block block,
|
||||||
|
required TextDirection textDirection,
|
||||||
|
required EdgeInsetsGeometry padding,
|
||||||
|
required double scrollBottomInset,
|
||||||
|
required Decoration decoration,
|
||||||
|
List<RenderEditableBox>? children,
|
||||||
|
ImageConfiguration configuration = ImageConfiguration.empty,
|
||||||
|
EdgeInsets contentPadding = EdgeInsets.zero,
|
||||||
|
}) : _decoration = decoration,
|
||||||
|
_configuration = configuration,
|
||||||
|
_savedPadding = padding,
|
||||||
|
_contentPadding = contentPadding,
|
||||||
|
super(
|
||||||
|
children,
|
||||||
|
block,
|
||||||
|
textDirection,
|
||||||
|
scrollBottomInset,
|
||||||
|
padding.add(contentPadding),
|
||||||
|
);
|
||||||
|
|
||||||
|
EdgeInsetsGeometry _savedPadding;
|
||||||
|
EdgeInsets _contentPadding;
|
||||||
|
|
||||||
|
set contentPadding(EdgeInsets value) {
|
||||||
|
if (_contentPadding == value) return;
|
||||||
|
_contentPadding = value;
|
||||||
|
super.setPadding(_savedPadding.add(_contentPadding));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setPadding(EdgeInsetsGeometry value) {
|
||||||
|
super.setPadding(value.add(_contentPadding));
|
||||||
|
_savedPadding = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxPainter? _painter;
|
||||||
|
|
||||||
|
Decoration get decoration => _decoration;
|
||||||
|
Decoration _decoration;
|
||||||
|
|
||||||
|
set decoration(Decoration value) {
|
||||||
|
if (value == _decoration) return;
|
||||||
|
_painter?.dispose();
|
||||||
|
_painter = null;
|
||||||
|
_decoration = value;
|
||||||
|
markNeedsPaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageConfiguration get configuration => _configuration;
|
||||||
|
ImageConfiguration _configuration;
|
||||||
|
|
||||||
|
set configuration(ImageConfiguration value) {
|
||||||
|
if (value == _configuration) return;
|
||||||
|
_configuration = value;
|
||||||
|
markNeedsPaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextRange getLineBoundary(TextPosition position) {
|
||||||
|
final child = childAtPosition(position);
|
||||||
|
final rangeInChild = child.getLineBoundary(TextPosition(
|
||||||
|
offset: position.offset - child.getContainer().offset,
|
||||||
|
affinity: position.affinity,
|
||||||
|
));
|
||||||
|
return TextRange(
|
||||||
|
start: rangeInChild.start + child.getContainer().offset,
|
||||||
|
end: rangeInChild.end + child.getContainer().offset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Offset getOffsetForCaret(TextPosition position) {
|
||||||
|
final child = childAtPosition(position);
|
||||||
|
return child.getOffsetForCaret(TextPosition(
|
||||||
|
offset: position.offset - child.getContainer().offset,
|
||||||
|
affinity: position.affinity,
|
||||||
|
)) +
|
||||||
|
(child.parentData as BoxParentData).offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextPosition getPositionForOffset(Offset offset) {
|
||||||
|
final child = childAtOffset(offset)!;
|
||||||
|
final parentData = child.parentData as BoxParentData;
|
||||||
|
final localPosition =
|
||||||
|
child.getPositionForOffset(offset - parentData.offset);
|
||||||
|
return TextPosition(
|
||||||
|
offset: localPosition.offset + child.getContainer().offset,
|
||||||
|
affinity: localPosition.affinity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextRange getWordBoundary(TextPosition position) {
|
||||||
|
final child = childAtPosition(position);
|
||||||
|
final nodeOffset = child.getContainer().offset;
|
||||||
|
final childWord = child
|
||||||
|
.getWordBoundary(TextPosition(offset: position.offset - nodeOffset));
|
||||||
|
return TextRange(
|
||||||
|
start: childWord.start + nodeOffset,
|
||||||
|
end: childWord.end + nodeOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextPosition? getPositionAbove(TextPosition position) {
|
||||||
|
assert(position.offset < getContainer().length);
|
||||||
|
|
||||||
|
final child = childAtPosition(position);
|
||||||
|
final childLocalPosition =
|
||||||
|
TextPosition(offset: position.offset - child.getContainer().offset);
|
||||||
|
final result = child.getPositionAbove(childLocalPosition);
|
||||||
|
if (result != null) {
|
||||||
|
return TextPosition(offset: result.offset + child.getContainer().offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
final sibling = childBefore(child);
|
||||||
|
if (sibling == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final caretOffset = child.getOffsetForCaret(childLocalPosition);
|
||||||
|
final testPosition =
|
||||||
|
TextPosition(offset: sibling.getContainer().length - 1);
|
||||||
|
final testOffset = sibling.getOffsetForCaret(testPosition);
|
||||||
|
final finalOffset = Offset(caretOffset.dx, testOffset.dy);
|
||||||
|
return TextPosition(
|
||||||
|
offset: sibling.getContainer().offset +
|
||||||
|
sibling.getPositionForOffset(finalOffset).offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextPosition? getPositionBelow(TextPosition position) {
|
||||||
|
assert(position.offset < getContainer().length);
|
||||||
|
|
||||||
|
final child = childAtPosition(position);
|
||||||
|
final childLocalPosition =
|
||||||
|
TextPosition(offset: position.offset - child.getContainer().offset);
|
||||||
|
final result = child.getPositionBelow(childLocalPosition);
|
||||||
|
if (result != null) {
|
||||||
|
return TextPosition(offset: result.offset + child.getContainer().offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
final sibling = childAfter(child);
|
||||||
|
if (sibling == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final caretOffset = child.getOffsetForCaret(childLocalPosition);
|
||||||
|
final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0));
|
||||||
|
final finalOffset = Offset(caretOffset.dx, testOffset.dy);
|
||||||
|
return TextPosition(
|
||||||
|
offset: sibling.getContainer().offset +
|
||||||
|
sibling.getPositionForOffset(finalOffset).offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double preferredLineHeight(TextPosition position) {
|
||||||
|
final child = childAtPosition(position);
|
||||||
|
return child.preferredLineHeight(
|
||||||
|
TextPosition(offset: position.offset - child.getContainer().offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextSelectionPoint getBaseEndpointForSelection(TextSelection selection) {
|
||||||
|
if (selection.isCollapsed) {
|
||||||
|
return TextSelectionPoint(
|
||||||
|
Offset(0, preferredLineHeight(selection.extent)) +
|
||||||
|
getOffsetForCaret(selection.extent),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final baseNode = getContainer().queryChild(selection.start, false).node;
|
||||||
|
var baseChild = firstChild;
|
||||||
|
while (baseChild != null) {
|
||||||
|
if (baseChild.getContainer() == baseNode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
baseChild = childAfter(baseChild);
|
||||||
|
}
|
||||||
|
assert(baseChild != null);
|
||||||
|
|
||||||
|
final basePoint = baseChild!.getBaseEndpointForSelection(
|
||||||
|
localSelection(baseChild.getContainer(), selection, true));
|
||||||
|
return TextSelectionPoint(
|
||||||
|
basePoint.point + (baseChild.parentData as BoxParentData).offset,
|
||||||
|
basePoint.direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextSelectionPoint getExtentEndpointForSelection(TextSelection selection) {
|
||||||
|
if (selection.isCollapsed) {
|
||||||
|
return TextSelectionPoint(
|
||||||
|
Offset(0, preferredLineHeight(selection.extent)) +
|
||||||
|
getOffsetForCaret(selection.extent),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final extentNode = getContainer().queryChild(selection.end, false).node;
|
||||||
|
|
||||||
|
var extentChild = firstChild;
|
||||||
|
while (extentChild != null) {
|
||||||
|
if (extentChild.getContainer() == extentNode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
extentChild = childAfter(extentChild);
|
||||||
|
}
|
||||||
|
assert(extentChild != null);
|
||||||
|
|
||||||
|
final extentPoint = extentChild!.getExtentEndpointForSelection(
|
||||||
|
localSelection(extentChild.getContainer(), selection, true));
|
||||||
|
return TextSelectionPoint(
|
||||||
|
extentPoint.point + (extentChild.parentData as BoxParentData).offset,
|
||||||
|
extentPoint.direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void detach() {
|
||||||
|
_painter?.dispose();
|
||||||
|
_painter = null;
|
||||||
|
super.detach();
|
||||||
|
markNeedsPaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(PaintingContext context, Offset offset) {
|
||||||
|
_paintDecoration(context, offset);
|
||||||
|
defaultPaint(context, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintDecoration(PaintingContext context, Offset offset) {
|
||||||
|
_painter ??= _decoration.createBoxPainter(markNeedsPaint);
|
||||||
|
|
||||||
|
final decorationPadding = resolvedPadding! - _contentPadding;
|
||||||
|
|
||||||
|
final filledConfiguration =
|
||||||
|
configuration.copyWith(size: decorationPadding.deflateSize(size));
|
||||||
|
final debugSaveCount = context.canvas.getSaveCount();
|
||||||
|
|
||||||
|
final decorationOffset =
|
||||||
|
offset.translate(decorationPadding.left, decorationPadding.top);
|
||||||
|
_painter!.paint(context.canvas, decorationOffset, filledConfiguration);
|
||||||
|
if (debugSaveCount != context.canvas.getSaveCount()) {
|
||||||
|
throw '${_decoration.runtimeType} painter had mismatching save and '
|
||||||
|
'restore calls.';
|
||||||
|
}
|
||||||
|
if (decoration.isComplex) {
|
||||||
|
context.setIsComplexHint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||||
|
return defaultHitTestChildren(result, position: position);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Rect getLocalRectForCaret(TextPosition position) {
|
||||||
|
final child = childAtPosition(position);
|
||||||
|
final localPosition = TextPosition(
|
||||||
|
offset: position.offset - child.getContainer().offset,
|
||||||
|
affinity: position.affinity,
|
||||||
|
);
|
||||||
|
final parentData = child.parentData as BoxParentData;
|
||||||
|
return child.getLocalRectForCaret(localPosition).shift(parentData.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextPosition globalToLocalPosition(TextPosition position) {
|
||||||
|
assert(getContainer().containsOffset(position.offset),
|
||||||
|
'The provided text position is not in the current node');
|
||||||
|
return TextPosition(
|
||||||
|
offset: position.offset - getContainer().documentOffset,
|
||||||
|
affinity: position.affinity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditableBlock extends MultiChildRenderObjectWidget {
|
||||||
|
_EditableBlock(
|
||||||
|
this.block,
|
||||||
|
this.textDirection,
|
||||||
|
this.padding,
|
||||||
|
this.scrollBottomInset,
|
||||||
|
this.decoration,
|
||||||
|
this.contentPadding,
|
||||||
|
List<Widget> children)
|
||||||
|
: super(children: children);
|
||||||
|
|
||||||
|
final Block block;
|
||||||
|
final TextDirection textDirection;
|
||||||
|
final Tuple2<double, double> padding;
|
||||||
|
final double scrollBottomInset;
|
||||||
|
final Decoration decoration;
|
||||||
|
final EdgeInsets? contentPadding;
|
||||||
|
|
||||||
|
EdgeInsets get _padding =>
|
||||||
|
EdgeInsets.only(top: padding.item1, bottom: padding.item2);
|
||||||
|
|
||||||
|
EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero;
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderEditableTextBlock createRenderObject(BuildContext context) {
|
||||||
|
return RenderEditableTextBlock(
|
||||||
|
block: block,
|
||||||
|
textDirection: textDirection,
|
||||||
|
padding: _padding,
|
||||||
|
scrollBottomInset: scrollBottomInset,
|
||||||
|
decoration: decoration,
|
||||||
|
contentPadding: _contentPadding,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(
|
||||||
|
BuildContext context, covariant RenderEditableTextBlock renderObject) {
|
||||||
|
renderObject
|
||||||
|
..setContainer(block)
|
||||||
|
..textDirection = textDirection
|
||||||
|
..scrollBottomInset = scrollBottomInset
|
||||||
|
..setPadding(_padding)
|
||||||
|
..decoration = decoration
|
||||||
|
..contentPadding = _contentPadding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NumberPoint extends StatelessWidget {
|
||||||
|
const _NumberPoint({
|
||||||
|
required this.index,
|
||||||
|
required this.indentLevelCounts,
|
||||||
|
required this.count,
|
||||||
|
required this.style,
|
||||||
|
required this.width,
|
||||||
|
required this.attrs,
|
||||||
|
this.withDot = true,
|
||||||
|
this.padding = 0.0,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
final Map<int?, int> indentLevelCounts;
|
||||||
|
final int count;
|
||||||
|
final TextStyle style;
|
||||||
|
final double width;
|
||||||
|
final Map<String, Attribute> attrs;
|
||||||
|
final bool withDot;
|
||||||
|
final double padding;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
var s = index.toString();
|
||||||
|
int? level = 0;
|
||||||
|
if (!attrs.containsKey(Attribute.indent.key) &&
|
||||||
|
!indentLevelCounts.containsKey(1)) {
|
||||||
|
indentLevelCounts.clear();
|
||||||
|
return Container(
|
||||||
|
alignment: AlignmentDirectional.topEnd,
|
||||||
|
width: width,
|
||||||
|
padding: EdgeInsetsDirectional.only(end: padding),
|
||||||
|
child: Text(withDot ? '$s.' : s, style: style),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (attrs.containsKey(Attribute.indent.key)) {
|
||||||
|
level = attrs[Attribute.indent.key]!.value;
|
||||||
|
} else {
|
||||||
|
// first level but is back from previous indent level
|
||||||
|
// supposed to be "2."
|
||||||
|
indentLevelCounts[0] = 1;
|
||||||
|
}
|
||||||
|
if (indentLevelCounts.containsKey(level! + 1)) {
|
||||||
|
// last visited level is done, going up
|
||||||
|
indentLevelCounts.remove(level + 1);
|
||||||
|
}
|
||||||
|
final count = (indentLevelCounts[level] ?? 0) + 1;
|
||||||
|
indentLevelCounts[level] = count;
|
||||||
|
|
||||||
|
s = count.toString();
|
||||||
|
if (level % 3 == 1) {
|
||||||
|
// a. b. c. d. e. ...
|
||||||
|
s = _toExcelSheetColumnTitle(count);
|
||||||
|
} else if (level % 3 == 2) {
|
||||||
|
// i. ii. iii. ...
|
||||||
|
s = _intToRoman(count);
|
||||||
|
}
|
||||||
|
// level % 3 == 0 goes back to 1. 2. 3.
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
alignment: AlignmentDirectional.topEnd,
|
||||||
|
width: width,
|
||||||
|
padding: EdgeInsetsDirectional.only(end: padding),
|
||||||
|
child: Text(withDot ? '$s.' : s, style: style),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _toExcelSheetColumnTitle(int n) {
|
||||||
|
final result = StringBuffer();
|
||||||
|
while (n > 0) {
|
||||||
|
n--;
|
||||||
|
result.write(String.fromCharCode((n % 26).floor() + 97));
|
||||||
|
n = (n / 26).floor();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString().split('').reversed.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _intToRoman(int input) {
|
||||||
|
var num = input;
|
||||||
|
|
||||||
|
if (num < 0) {
|
||||||
|
return '';
|
||||||
|
} else if (num == 0) {
|
||||||
|
return 'nulla';
|
||||||
|
}
|
||||||
|
|
||||||
|
final builder = StringBuffer();
|
||||||
|
for (var a = 0; a < arabianRomanNumbers.length; a++) {
|
||||||
|
final times = (num / arabianRomanNumbers[a])
|
||||||
|
.truncate(); // equals 1 only when arabianRomanNumbers[a] = num
|
||||||
|
// executes n times where n is the number of times you have to add
|
||||||
|
// the current roman number value to reach current num.
|
||||||
|
builder.write(romanNumbers[a] * times);
|
||||||
|
num -= times *
|
||||||
|
arabianRomanNumbers[
|
||||||
|
a]; // subtract previous roman number value from num
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toString().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BulletPoint extends StatelessWidget {
|
||||||
|
const _BulletPoint({
|
||||||
|
required this.style,
|
||||||
|
required this.width,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final TextStyle style;
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
alignment: AlignmentDirectional.topEnd,
|
||||||
|
width: width,
|
||||||
|
padding: const EdgeInsetsDirectional.only(end: 13),
|
||||||
|
child: Text('•', style: style),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Checkbox extends StatelessWidget {
|
||||||
|
const _Checkbox({
|
||||||
|
Key? key,
|
||||||
|
this.style,
|
||||||
|
this.width,
|
||||||
|
this.isChecked = false,
|
||||||
|
this.offset,
|
||||||
|
this.onTap,
|
||||||
|
}) : super(key: key);
|
||||||
|
final TextStyle? style;
|
||||||
|
final double? width;
|
||||||
|
final bool isChecked;
|
||||||
|
final int? offset;
|
||||||
|
final Function(int, bool)? onTap;
|
||||||
|
|
||||||
|
void _onCheckboxClicked(bool? newValue) {
|
||||||
|
if (onTap != null && newValue != null && offset != null) {
|
||||||
|
onTap!(offset!, newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
alignment: AlignmentDirectional.topEnd,
|
||||||
|
width: width,
|
||||||
|
padding: const EdgeInsetsDirectional.only(end: 13),
|
||||||
|
child: GestureDetector(
|
||||||
|
onLongPress: () => _onCheckboxClicked(!isChecked),
|
||||||
|
child: Checkbox(
|
||||||
|
value: isChecked,
|
||||||
|
onChanged: _onCheckboxClicked,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|