[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 */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@ -31,7 +32,11 @@
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -49,12 +54,21 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9D1D47ADD7F5DE8237063BCA /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
78844014EF958DCBB6F9B4EA /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
197F72694BED43249F1523E8 /* Pods_Runner.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -72,6 +86,8 @@
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
9EC83BEE9154F1BD11D24F8F /* Pods */,
|
||||
78844014EF958DCBB6F9B4EA /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -98,6 +114,17 @@
|
||||
path = Runner;
|
||||
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 */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@ -105,12 +132,14 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
E790B8FE5609053209ED85CB /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
08FAA63113168DEC7FB74204 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -169,6 +198,23 @@
|
||||
/* End PBXResourcesBuildPhase 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 */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -197,6 +243,28 @@
|
||||
shellPath = /bin/sh;
|
||||
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 */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
|
@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
@ -1,9 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flowy_editor/flowy_editor.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
// 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_sdk/protobuf/flowy-document/doc.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/workspace/application/doc/doc_edit_bloc.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_bloc/flutter_bloc.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class DocPage extends StatefulWidget {
|
||||
late EditorController controller;
|
||||
late QuillController controller;
|
||||
late DocEditBloc editBloc;
|
||||
final FlowyDoc doc;
|
||||
|
||||
DocPage({Key? key, required this.doc}) : super(key: key) {
|
||||
editBloc = getIt<DocEditBloc>(param1: doc.id);
|
||||
controller = EditorController(
|
||||
controller = QuillController(
|
||||
document: doc.document,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
@ -54,8 +54,8 @@ class _DocPageState extends State<DocPage> {
|
||||
await widget.doc.close();
|
||||
}
|
||||
|
||||
Widget _renderEditor(EditorController controller) {
|
||||
final editor = FlowyEditor(
|
||||
Widget _renderEditor(QuillController controller) {
|
||||
final editor = QuillEditor(
|
||||
controller: controller,
|
||||
focusNode: _focusNode,
|
||||
scrollable: true,
|
||||
@ -71,10 +71,9 @@ class _DocPageState extends State<DocPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderToolbar(EditorController controller) {
|
||||
return FlowyToolbar.basic(
|
||||
Widget _renderToolbar(QuillController controller) {
|
||||
return QuillToolbar.basic(
|
||||
controller: controller,
|
||||
onImageSelectCallback: _onImageSelection,
|
||||
);
|
||||
}
|
||||
|
||||
@ -82,3 +81,81 @@ class _DocPageState extends State<DocPage> {
|
||||
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 Foundation
|
||||
|
||||
import editor
|
||||
import flowy_editor
|
||||
import flowy_infra_ui
|
||||
import flowy_sdk
|
||||
@ -13,6 +14,7 @@ import url_launcher_macos
|
||||
import window_size
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
EditorPlugin.register(with: registry.registrar(forPlugin: "EditorPlugin"))
|
||||
FlowyEditorPlugin.register(with: registry.registrar(forPlugin: "FlowyEditorPlugin"))
|
||||
FlowyInfraUIPlugin.register(with: registry.registrar(forPlugin: "FlowyInfraUIPlugin"))
|
||||
FlowySdkPlugin.register(with: registry.registrar(forPlugin: "FlowySdkPlugin"))
|
||||
|
@ -1,4 +1,6 @@
|
||||
PODS:
|
||||
- editor (0.0.1):
|
||||
- FlutterMacOS
|
||||
- flowy_editor (0.0.1):
|
||||
- FlutterMacOS
|
||||
- flowy_infra_ui (0.0.1):
|
||||
@ -14,6 +16,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- editor (from `Flutter/ephemeral/.symlinks/plugins/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_sdk (from `Flutter/ephemeral/.symlinks/plugins/flowy_sdk/macos`)
|
||||
@ -23,6 +26,8 @@ DEPENDENCIES:
|
||||
- window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
editor:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/editor/macos
|
||||
flowy_editor:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flowy_editor/macos
|
||||
flowy_infra_ui:
|
||||
@ -39,6 +44,7 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_size/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
editor: 380351c0334fbeb0e431e4e49629c9e2d925b66d
|
||||
flowy_editor: 26060a984848e6afac1f6a4455511f4114119d8d
|
||||
flowy_infra_ui: 9d5021b1610fe0476eb1191bf7cd41c4a4138d8f
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|