[flutter]: add flutter_quill for test

This commit is contained in:
appflowy 2021-10-09 07:14:21 +08:00
parent c4e9fd0697
commit 2193879b5f
150 changed files with 15746 additions and 27 deletions

View 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

View File

@ -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 */

View File

@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@ -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';

View File

@ -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();
// }
// }

View File

@ -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"))

View File

@ -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
View 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/

View 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

View File

@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.

View File

@ -0,0 +1 @@
TODO: Add your license here.

View 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.

View 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

View 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

View 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

View 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.

View 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

View 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'),
),
),
);
}
}

View File

@ -0,0 +1,7 @@
# Flutter-related
**/Flutter/ephemeral/
**/Pods/
# Xcode-related
**/dgph
**/xcuserdata/

View File

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@ -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"))
}

View 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

View File

@ -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 */;
}

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -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>

View File

@ -0,0 +1,9 @@
import Cocoa
import FlutterMacOS
@NSApplicationMain
class AppDelegate: FlutterAppDelegate {
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -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>

View File

@ -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.

View File

@ -0,0 +1,2 @@
#include "../../Flutter/Flutter-Debug.xcconfig"
#include "Warnings.xcconfig"

View File

@ -0,0 +1,2 @@
#include "../../Flutter/Flutter-Release.xcconfig"
#include "Warnings.xcconfig"

View File

@ -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

View File

@ -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>

View 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>

View File

@ -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()
}
}

View File

@ -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>

View 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"

View 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

View 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() {}

View 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';

View 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/documents/attribute.dart';

View 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/documents/document.dart';

View 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/documents/history.dart';

View 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/documents/nodes/block.dart';

View 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/documents/nodes/container.dart';

View 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/documents/nodes/embed.dart';

View 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/documents/nodes/leaf.dart';

View 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/documents/nodes/line.dart';

View 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/documents/nodes/node.dart';

View 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/documents/style.dart';

View 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';

View 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';

View 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';

View 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';

View 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';

View File

@ -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);
}

View 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,
}

View 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();
}
}

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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));
}

View 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(', ')}}";
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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);
}

View 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';
}
}

View 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);
}

View 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;
}

View File

@ -0,0 +1,4 @@
enum MediaPickSetting {
Gallery,
Link,
}

View 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;
}

View 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);
}

View 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));
}
}

View 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);
}
}

View 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);
}
}

View 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,
);
}
}

File diff suppressed because it is too large Load Diff

View 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,
),
),
),
);
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View 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);
}
}

View 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,
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More